@intentius/behold 0.10.1 → 0.12.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 { 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);
@@ -1552,8 +1554,15 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1552
1554
  result.push(obj);
1553
1555
  i = j;
1554
1556
  } else {
1555
- result.push(parseScalar(itemValue));
1556
- i++;
1557
+ const header = blockScalarHeader(itemValue);
1558
+ if (header) {
1559
+ const block = parseBlockScalar(lines, i + 1, indent, header);
1560
+ result.push(block.value);
1561
+ i = block.endIndex;
1562
+ } else {
1563
+ result.push(parseScalar(itemValue));
1564
+ i++;
1565
+ }
1557
1566
  }
1558
1567
  } else {
1559
1568
  break;
@@ -1565,13 +1574,68 @@ function parseScalar(value) {
1565
1574
  if (value === "" || value === "~" || value === "null") return null;
1566
1575
  if (value === "true" || value === "yes") return true;
1567
1576
  if (value === "false" || value === "no") return false;
1568
- if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
1569
- 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
+ }
1570
1585
  }
1571
1586
  const num = Number(value);
1572
1587
  if (!isNaN(num) && value !== "") return num;
1573
1588
  return value;
1574
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
+ }
1575
1639
 
1576
1640
  // src/chant.ts
1577
1641
  function envOverridesFor(opts) {
@@ -1829,6 +1893,25 @@ function lifecycleDiffArgs(env) {
1829
1893
  function lifecycleDiffLive(projectDir, env, opts = {}) {
1830
1894
  return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
1831
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
+ }
1832
1915
  function applyArgs(target, env) {
1833
1916
  return ["run", target, "--components", "--env", env, "--progress-json"];
1834
1917
  }
@@ -2035,19 +2118,19 @@ function finishPipelineProgress(state, exitCode) {
2035
2118
  }
2036
2119
 
2037
2120
  // src/gh-run.ts
2038
- var defaultGhExec = (args) => new Promise((resolve8) => {
2121
+ var defaultGhExec = (args) => new Promise((resolve9) => {
2039
2122
  let out = "";
2040
2123
  let proc;
2041
2124
  try {
2042
2125
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
2043
2126
  } catch {
2044
- resolve8({ code: 127, out: "" });
2127
+ resolve9({ code: 127, out: "" });
2045
2128
  return;
2046
2129
  }
2047
2130
  proc.stdout.on("data", (d) => out += d);
2048
2131
  proc.stderr.on("data", (d) => out += d);
2049
- proc.on("error", () => resolve8({ code: 127, out }));
2050
- 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 }));
2051
2134
  });
2052
2135
  async function ghReady(exec = defaultGhExec) {
2053
2136
  const { code, out } = await exec(["auth", "status"]);
@@ -2591,127 +2674,548 @@ function applyHelmArtifacts(ir, observed) {
2591
2674
  return { charts, installed };
2592
2675
  }
2593
2676
 
2594
- // src/cluster-anchor.ts
2595
- var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
2596
- "AWS::EKS::Cluster",
2597
- "GCP::Container::Cluster",
2598
- "Microsoft.ContainerService/managedClusters",
2599
- "K3d::Cluster"
2600
- ]);
2601
- var ANCHOR_VIA = "runs-on";
2602
- function metadata(node) {
2603
- const m = node.attrs?.metadata;
2604
- return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
2677
+ // src/estate.ts
2678
+ import { statSync as statSync3 } from "node:fs";
2679
+ import { availableParallelism } from "node:os";
2680
+ import { join as join7, resolve as resolve3, sep } from "node:path";
2681
+ import { composeStacks, shortStackNames } from "@intentius/pinhole";
2682
+
2683
+ // src/member-ir.ts
2684
+ import { createHash } from "node:crypto";
2685
+ import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
2686
+ import { join as join6, relative, resolve as resolve2 } from "node:path";
2687
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
2688
+ function memberIrCacheSize(env = process.env) {
2689
+ const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
2690
+ return Number.isInteger(override) && override >= 0 ? override : 64;
2691
+ }
2692
+ function memberSourceStamp(dir) {
2693
+ const root = resolve2(dir);
2694
+ const h = createHash("sha1");
2695
+ let any = false;
2696
+ const walk = (at) => {
2697
+ const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
2698
+ for (const e of entries) {
2699
+ if (SKIP.has(e.name)) continue;
2700
+ const path = join6(at, e.name);
2701
+ if (e.isDirectory()) {
2702
+ walk(path);
2703
+ continue;
2704
+ }
2705
+ if (!e.isFile()) continue;
2706
+ const st = statSync2(path);
2707
+ h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
2708
+ `);
2709
+ any = true;
2710
+ }
2711
+ };
2712
+ try {
2713
+ walk(root);
2714
+ } catch {
2715
+ return void 0;
2716
+ }
2717
+ return any ? h.digest("hex") : void 0;
2605
2718
  }
2606
- function metaString(node, key) {
2607
- const v = metadata(node)?.[key];
2608
- return typeof v === "string" && v.length > 0 ? v : void 0;
2719
+ function cacheKey(dir, opts) {
2720
+ const chant = resolveChant(dir);
2721
+ const canonical = JSON.stringify(opts, Object.keys(opts).sort());
2722
+ return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
2609
2723
  }
2610
- function isK8s(node) {
2611
- return node.lexicon === "k8s";
2724
+ var cache = /* @__PURE__ */ new Map();
2725
+ var hits = 0;
2726
+ var misses = 0;
2727
+ function invalidateMember(dir) {
2728
+ if (dir === void 0) {
2729
+ cache.clear();
2730
+ return;
2731
+ }
2732
+ const root = resolve2(dir);
2733
+ for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
2734
+ }
2735
+ async function memberIr(dir, opts = {}) {
2736
+ const size = memberIrCacheSize();
2737
+ if (opts.live || opts.overlay || size === 0) return graphIr(dir, opts);
2738
+ const stamp = memberSourceStamp(dir);
2739
+ const key = cacheKey(dir, opts);
2740
+ const hit = stamp !== void 0 ? cache.get(key) : void 0;
2741
+ if (hit && hit.stamp === stamp) {
2742
+ hits++;
2743
+ cache.delete(key);
2744
+ cache.set(key, hit);
2745
+ return structuredClone(hit.ir);
2746
+ }
2747
+ misses++;
2748
+ const ir = await graphIr(dir, opts);
2749
+ if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
2750
+ cache.delete(key);
2751
+ cache.set(key, { dir: resolve2(dir), stamp, ir });
2752
+ for (const oldest of cache.keys()) {
2753
+ if (cache.size <= size) break;
2754
+ cache.delete(oldest);
2755
+ }
2756
+ return structuredClone(ir);
2612
2757
  }
2613
- function isNamespace(node) {
2614
- return node.kind === "K8s::Core::Namespace";
2758
+
2759
+ // src/zoom-notes.ts
2760
+ function zoomNote(zoom, ir, compositeEdgesAttached) {
2761
+ const empty = ir.nodes.length === 0;
2762
+ if (zoom === "components") {
2763
+ return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
2764
+ }
2765
+ if (zoom === "logical") {
2766
+ return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
2767
+ }
2768
+ if (zoom === "composites") {
2769
+ return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
2770
+ }
2771
+ if (zoom === "runtime") {
2772
+ const children = ir.nodes.filter((n) => n.runtimeOwner).length;
2773
+ return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
2774
+ }
2775
+ return void 0;
2615
2776
  }
2616
- function managedClusterName(node) {
2617
- const metaName = metaString(node, "name");
2618
- if (metaName) return metaName;
2619
- for (const key of ["name", "clusterName"]) {
2620
- const v = node.attrs?.[key];
2621
- if (typeof v === "string" && v.length > 0) return v;
2777
+ function logicalKept(before, after) {
2778
+ if (after > 0 && after * 3 >= before) return void 0;
2779
+ 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)`;
2780
+ }
2781
+ function edgelessNote(zoom, ir, detail) {
2782
+ if (zoom === "components" || zoom === "logical") return void 0;
2783
+ if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
2784
+ if (detail !== void 0 && detail < 3) {
2785
+ 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)";
2622
2786
  }
2623
- return node.id;
2787
+ return "no edges \u2014 nothing in this estate references anything else";
2624
2788
  }
2625
- function boundManagedCluster(nodes, boundContext) {
2626
- const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
2627
- if (clusters.length === 1) return clusters[0];
2628
- if (clusters.length === 0 || !boundContext) return void 0;
2629
- const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
2630
- return bound.length === 1 ? bound[0] : void 0;
2789
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
2790
+ "K8s::Core::Namespace",
2791
+ "K8s::Rbac::ClusterRole",
2792
+ "K8s::Rbac::ClusterRoleBinding",
2793
+ "K8s::Storage::StorageClass",
2794
+ "K8s::Apiextensions::CustomResourceDefinition"
2795
+ ]);
2796
+ function namespaceMismatchNote(nodes) {
2797
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2798
+ if (k8s.length === 0) return void 0;
2799
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2800
+ if (pending.length < 2) return void 0;
2801
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2802
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2803
+ 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`;
2631
2804
  }
2632
- function addClusterAnchorEdges(ir, boundContext) {
2633
- const nodes = ir.nodes;
2634
- const cluster = boundManagedCluster(ir.nodes, boundContext);
2635
- if (!cluster) return ir;
2636
- const k8sNodes = nodes.filter(isK8s);
2637
- if (k8sNodes.length === 0) return ir;
2638
- const namespaceByName = /* @__PURE__ */ new Map();
2639
- for (const n of k8sNodes) {
2640
- if (!isNamespace(n)) continue;
2641
- const name = metaString(n, "name");
2642
- if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
2805
+ function namespaceJoinNote(joined) {
2806
+ if (joined.length === 0) return void 0;
2807
+ const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
2808
+ return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
2809
+ }
2810
+ function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
2811
+ const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2812
+ const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
2813
+ return notes.length ? notes.join(" \xB7 ") : void 0;
2814
+ }
2815
+ function tierMismatchNote(ir, tiers, currentTier) {
2816
+ const values = tiers?.values ?? [];
2817
+ if (values.length < 2) return void 0;
2818
+ const byLexicon = /* @__PURE__ */ new Map();
2819
+ for (const n of ir.nodes) {
2820
+ const status = n.attrs?._status;
2821
+ if (status !== "good" && status !== "accent") continue;
2822
+ const lexicon = n.lexicon ?? "?";
2823
+ const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
2824
+ c[status === "good" ? "good" : "accent"]++;
2825
+ byLexicon.set(lexicon, c);
2643
2826
  }
2644
- const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
2645
- const add = (from, to) => {
2646
- if (from === to) return;
2647
- const key = `${from}\0${to}`;
2648
- if (existing.has(key)) return;
2649
- existing.add(key);
2650
- ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
2651
- };
2652
- for (const n of k8sNodes) {
2653
- if (isNamespace(n)) {
2654
- add(cluster.id, n.id);
2655
- continue;
2827
+ for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
2828
+ if (accent < 2 || accent * 2 < good + accent) continue;
2829
+ const here = currentTier ? `tier "${currentTier}"` : "the default tier";
2830
+ const others = values.filter((v) => v !== currentTier).join(", ");
2831
+ 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)`;
2832
+ }
2833
+ return void 0;
2834
+ }
2835
+
2836
+ // src/estate.ts
2837
+ function estateReadPool(members, env = process.env) {
2838
+ const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
2839
+ const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
2840
+ return Math.max(1, Math.min(cap, members));
2841
+ }
2842
+ async function mapPool(items, width, fn) {
2843
+ const results = new Array(items.length);
2844
+ let next = 0;
2845
+ const worker = async () => {
2846
+ for (let i = next++; i < items.length; i = next++) {
2847
+ results[i] = await fn(items[i], i);
2656
2848
  }
2657
- const ns = metaString(n, "namespace");
2658
- const nsNode = ns ? namespaceByName.get(ns) : void 0;
2659
- add(nsNode ?? cluster.id, n.id);
2849
+ };
2850
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
2851
+ return results;
2852
+ }
2853
+ async function composeEstate(projectDirs, opts = {}) {
2854
+ const names = shortStackNames(projectDirs);
2855
+ const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
2856
+ name: names[i],
2857
+ ir: await memberIr(dir, opts)
2858
+ }));
2859
+ return composeStacks(stacks);
2860
+ }
2861
+ var firstLine2 = (e) => {
2862
+ const msg = e instanceof Error ? e.message : String(e);
2863
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
2864
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
2865
+ };
2866
+ function namespaceRuntimeOwners(name, ir) {
2867
+ const own = new Set(ir.nodes.map((n) => n.id));
2868
+ for (const n of ir.nodes) {
2869
+ if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
2660
2870
  }
2661
2871
  return ir;
2662
2872
  }
2663
-
2664
- // src/logical-azure.ts
2665
- var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
2666
- // compute / workloads
2667
- "Microsoft.Compute/virtualMachines",
2668
- "Microsoft.Compute/virtualMachineScaleSets",
2669
- "Microsoft.ContainerService/managedClusters",
2670
- "Microsoft.ContainerInstance/containerGroups",
2671
- "Microsoft.Web/sites",
2672
- // network edge
2673
- "Microsoft.Network/publicIPAddresses",
2674
- "Microsoft.Network/loadBalancers",
2675
- "Microsoft.Network/applicationGateways",
2676
- "Microsoft.Network/natGateways",
2677
- "Microsoft.Network/dnsZones",
2678
- // data stores
2679
- "Microsoft.Storage/storageAccounts",
2680
- "Microsoft.Sql/servers",
2681
- "Microsoft.DocumentDB/databaseAccounts",
2682
- "Microsoft.Cache/redis",
2683
- "Microsoft.DBforPostgreSQL/flexibleServers",
2684
- // registry / identity edge
2685
- "Microsoft.ContainerRegistry/registries"
2686
- ]);
2687
- var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
2688
- "Microsoft.Network/virtualNetworks",
2689
- "Microsoft.Network/virtualNetworks_subnets",
2690
- "Microsoft.Network/networkSecurityGroups",
2691
- "Microsoft.Network/routeTables",
2692
- "Microsoft.Network/networkInterfaces",
2693
- // identity/authorization hubs — a role assignment or managed identity is
2694
- // referenced by half the estate, so contracting through one invents edges
2695
- // between unrelated resources.
2696
- "Microsoft.Authorization/roleAssignments",
2697
- "Microsoft.ManagedIdentity/userAssignedIdentities",
2698
- "Microsoft.KeyVault/vaults"
2699
- ]);
2700
- var VNET_KIND = "Microsoft.Network/virtualNetworks";
2701
- var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
2702
- var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
2703
- var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
2704
- var QUOTED_RE = /'([^']*)'/g;
2705
- function resourceIdNames(value) {
2706
- if (typeof value !== "string" || !value.includes("resourceId(")) return [];
2873
+ var NAMESPACE_JOIN_FLOOR = "0.44.5";
2874
+ var rec2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2875
+ var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
2876
+ var realIsDir = (p) => {
2877
+ try {
2878
+ return statSync3(p).isDirectory();
2879
+ } catch {
2880
+ return false;
2881
+ }
2882
+ };
2883
+ function declaredNamespaceBindings(ir) {
2707
2884
  const out = [];
2708
- for (const call of value.matchAll(RESOURCE_ID_RE)) {
2709
- const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
2710
- if (args.length >= 2) out.push(args[args.length - 1]);
2885
+ for (const n of ir.nodes) {
2886
+ if (n.kind !== "K8s::Flux::Kustomization") continue;
2887
+ const spec = rec2(n.attrs?.spec);
2888
+ const namespace = str2(spec?.targetNamespace);
2889
+ const path = str2(spec?.path);
2890
+ if (namespace && path) out.push({ path, namespace });
2711
2891
  }
2712
2892
  return out;
2713
2893
  }
2714
- function collectAzureRefs(value, out) {
2894
+ function awaitsNamespaceBinding(ir) {
2895
+ const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2896
+ if (namespaced.length === 0) return false;
2897
+ return namespaced.every((n) => !str2(rec2(n.attrs?.metadata)?.namespace));
2898
+ }
2899
+ function declaredSegments(path) {
2900
+ if (path.startsWith("/")) return void 0;
2901
+ const segs = path.split("/").filter((s) => s !== "" && s !== ".");
2902
+ if (segs.length === 0 || segs.includes("..")) return void 0;
2903
+ return segs;
2904
+ }
2905
+ function pathAlignment(declared, dir, isDir = realIsDir) {
2906
+ const want = declaredSegments(declared);
2907
+ if (!want) return 0;
2908
+ const root = resolve3(dir);
2909
+ const have = root.split(sep).filter(Boolean);
2910
+ for (let k = Math.min(want.length, have.length); k >= 1; k--) {
2911
+ if (!have.slice(-k).every((s, i) => s === want[i])) continue;
2912
+ if (!isDir(join7(root, ...want.slice(k)))) continue;
2913
+ return k;
2914
+ }
2915
+ return 0;
2916
+ }
2917
+ function joinNamespaceBindings(members, isDir = realIsDir) {
2918
+ const bindings = members.flatMap(
2919
+ (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
2920
+ );
2921
+ if (bindings.length === 0) return [];
2922
+ const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
2923
+ if (awaiting.length === 0) return [];
2924
+ const best = /* @__PURE__ */ new Map();
2925
+ for (const b of bindings) {
2926
+ let winner;
2927
+ let tied = false;
2928
+ for (const m of awaiting) {
2929
+ if (m.dir === b.declaredBy) continue;
2930
+ const score = pathAlignment(b.path, m.dir, isDir);
2931
+ if (score === 0) continue;
2932
+ if (!winner || score > winner.score) {
2933
+ winner = { dir: m.dir, score };
2934
+ tied = false;
2935
+ } else if (score === winner.score) {
2936
+ tied = true;
2937
+ }
2938
+ }
2939
+ if (!winner || tied) continue;
2940
+ const prev = best.get(winner.dir);
2941
+ if (prev === "ambiguous") continue;
2942
+ const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
2943
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
2944
+ else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
2945
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
2946
+ }
2947
+ return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
2948
+ }
2949
+ async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
2950
+ if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
2951
+ const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
2952
+ const irs = await mapPool(
2953
+ projectDirs,
2954
+ estateReadPool(projectDirs.length),
2955
+ (dir) => memberIr(dir, { ...src, detail: 3 }).catch(() => void 0)
2956
+ );
2957
+ const scopes = /* @__PURE__ */ new Map();
2958
+ for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
2959
+ if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
2960
+ scopes.set(j.dir, j.namespace);
2961
+ }
2962
+ return scopes;
2963
+ }
2964
+ function withoutJoinedMembers(nodes, joined) {
2965
+ if (joined.length === 0) return [...nodes];
2966
+ return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
2967
+ }
2968
+ async function composeEstateOverlay(projectDirs, opts, classify) {
2969
+ const names = shortStackNames(projectDirs);
2970
+ const unobserved = [];
2971
+ const dropped = [];
2972
+ const joined = [];
2973
+ const stacks = new Array(projectDirs.length);
2974
+ const scopes = await estateNamespaceScopes(projectDirs, opts);
2975
+ await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
2976
+ const name = names[i];
2977
+ const namespace = scopes.get(dir);
2978
+ try {
2979
+ const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
2980
+ stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
2981
+ if (namespace) joined.push({ name, namespace });
2982
+ } catch (err) {
2983
+ const reason = firstLine2(err);
2984
+ try {
2985
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
2986
+ const src = await memberIr(dir, srcOpts);
2987
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
2988
+ stacks[i] = { name, ir: src };
2989
+ unobserved.push({ name, reason });
2990
+ } catch (err2) {
2991
+ dropped.push({ name, reason: firstLine2(err2) });
2992
+ }
2993
+ }
2994
+ });
2995
+ const present = stacks.filter((s) => !!s);
2996
+ return {
2997
+ ir: composeStacks(present),
2998
+ observed: present.length - unobserved.length,
2999
+ total: projectDirs.length,
3000
+ unobserved,
3001
+ dropped,
3002
+ // Concurrent reads finish in whatever order the clusters answer; the note
3003
+ // this feeds should read the same twice.
3004
+ joined: joined.sort((a, b) => a.name.localeCompare(b.name))
3005
+ };
3006
+ }
3007
+
3008
+ // src/helm-drift.ts
3009
+ function countChanges(docs) {
3010
+ return docs.reduce((n, d) => n + d.changes.length, 0);
3011
+ }
3012
+ function provenanceOf(live) {
3013
+ const m = live.manifest;
3014
+ return {
3015
+ contentDigest: m.contentDigest,
3016
+ inputDigest: m.inputDigest,
3017
+ valuesDigest: m.valuesDigest,
3018
+ chart: m.chart,
3019
+ chartVersion: m.chartVersion,
3020
+ repo: m.repo,
3021
+ releaseName: m.releaseName,
3022
+ namespace: m.namespace,
3023
+ profile: m.capabilityProfile?.cluster ?? "",
3024
+ kubeVersion: m.capabilityProfile?.kubeVersion ?? "",
3025
+ renderedAt: m.renderedAt,
3026
+ helmVersion: m.helmVersion,
3027
+ chantVersion: m.chantVersion,
3028
+ sourceRef: m.sourceRef
3029
+ };
3030
+ }
3031
+ function helmRenderReport(record, live) {
3032
+ const base = { render: record.name, chart: record.chart };
3033
+ if (!live) {
3034
+ return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
3035
+ }
3036
+ const d = live.diff;
3037
+ const counts = {
3038
+ drifted: d.drifted.length,
3039
+ changes: countChanges(d.drifted),
3040
+ unchanged: d.unchanged.length,
3041
+ unobserved: d.unobserved.length,
3042
+ undeclared: d.undeclaredEntities.length
3043
+ };
3044
+ const provenance = provenanceOf(live);
3045
+ if (d.drifted.length > 0) {
3046
+ return { ...base, verdict: "drifted", counts, provenance, drifted: d.drifted };
3047
+ }
3048
+ if (d.drifted.length + d.accepted.length + d.unchanged.length === 0) {
3049
+ return { ...base, verdict: "unobserved", reason: "nothing-observed", counts, provenance };
3050
+ }
3051
+ if (d.unobserved.length > 0) {
3052
+ return { ...base, verdict: "unobserved", reason: "partly-observed", counts, provenance };
3053
+ }
3054
+ return { ...base, verdict: "in-sync", counts, provenance };
3055
+ }
3056
+ async function readHelmRenderDrift(projectDir, env, opts = {}) {
3057
+ const report = await helmRenders(projectDir, opts).catch(() => void 0);
3058
+ const records = report?.records ?? [];
3059
+ if (records.length === 0) return [];
3060
+ return mapPool(records, estateReadPool(records.length), async (record) => {
3061
+ if (!record.contentDigest) return helmRenderReport(record, void 0);
3062
+ const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
3063
+ return helmRenderReport(record, live);
3064
+ });
3065
+ }
3066
+ function chartNameOf2(node) {
3067
+ const name = node.attrs?.name;
3068
+ return typeof name === "string" && name.length > 0 ? name : void 0;
3069
+ }
3070
+ var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
3071
+ function applyHelmRenderDrift(ir, reports) {
3072
+ if (reports.length === 0) return { charts: 0, drifted: 0 };
3073
+ const byChart = /* @__PURE__ */ new Map();
3074
+ for (const r of reports) {
3075
+ const list3 = byChart.get(r.chart);
3076
+ if (list3) list3.push(r);
3077
+ else byChart.set(r.chart, [r]);
3078
+ }
3079
+ let charts = 0;
3080
+ let drifted = 0;
3081
+ for (const node of ir.nodes) {
3082
+ if (node.lexicon !== "helm" || node.kind !== "Helm::Chart") continue;
3083
+ const chartName = chartNameOf2(node);
3084
+ const matched = chartName ? byChart.get(chartName) : void 0;
3085
+ if (!matched || matched.length === 0) continue;
3086
+ const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
3087
+ const attrs = node.attrs ??= {};
3088
+ attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
3089
+ charts++;
3090
+ if (worst.verdict === "drifted") {
3091
+ attrs._status = "warn";
3092
+ drifted++;
3093
+ }
3094
+ }
3095
+ return { charts, drifted };
3096
+ }
3097
+
3098
+ // src/cluster-anchor.ts
3099
+ var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
3100
+ "AWS::EKS::Cluster",
3101
+ "GCP::Container::Cluster",
3102
+ "Microsoft.ContainerService/managedClusters",
3103
+ "K3d::Cluster"
3104
+ ]);
3105
+ var ANCHOR_VIA = "runs-on";
3106
+ function metadata(node) {
3107
+ const m = node.attrs?.metadata;
3108
+ return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
3109
+ }
3110
+ function metaString(node, key) {
3111
+ const v = metadata(node)?.[key];
3112
+ return typeof v === "string" && v.length > 0 ? v : void 0;
3113
+ }
3114
+ function isK8s(node) {
3115
+ return node.lexicon === "k8s";
3116
+ }
3117
+ function isNamespace(node) {
3118
+ return node.kind === "K8s::Core::Namespace";
3119
+ }
3120
+ function managedClusterName(node) {
3121
+ const metaName = metaString(node, "name");
3122
+ if (metaName) return metaName;
3123
+ for (const key of ["name", "clusterName"]) {
3124
+ const v = node.attrs?.[key];
3125
+ if (typeof v === "string" && v.length > 0) return v;
3126
+ }
3127
+ return node.id;
3128
+ }
3129
+ function boundManagedCluster(nodes, boundContext) {
3130
+ const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
3131
+ if (clusters.length === 1) return clusters[0];
3132
+ if (clusters.length === 0 || !boundContext) return void 0;
3133
+ const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
3134
+ return bound.length === 1 ? bound[0] : void 0;
3135
+ }
3136
+ function addClusterAnchorEdges(ir, boundContext) {
3137
+ const nodes = ir.nodes;
3138
+ const cluster = boundManagedCluster(ir.nodes, boundContext);
3139
+ if (!cluster) return ir;
3140
+ const k8sNodes = nodes.filter(isK8s);
3141
+ if (k8sNodes.length === 0) return ir;
3142
+ const namespaceByName = /* @__PURE__ */ new Map();
3143
+ for (const n of k8sNodes) {
3144
+ if (!isNamespace(n)) continue;
3145
+ const name = metaString(n, "name");
3146
+ if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
3147
+ }
3148
+ const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
3149
+ const add = (from, to) => {
3150
+ if (from === to) return;
3151
+ const key = `${from}\0${to}`;
3152
+ if (existing.has(key)) return;
3153
+ existing.add(key);
3154
+ ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
3155
+ };
3156
+ for (const n of k8sNodes) {
3157
+ if (isNamespace(n)) {
3158
+ add(cluster.id, n.id);
3159
+ continue;
3160
+ }
3161
+ const ns = metaString(n, "namespace");
3162
+ const nsNode = ns ? namespaceByName.get(ns) : void 0;
3163
+ add(nsNode ?? cluster.id, n.id);
3164
+ }
3165
+ return ir;
3166
+ }
3167
+
3168
+ // src/logical-azure.ts
3169
+ var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
3170
+ // compute / workloads
3171
+ "Microsoft.Compute/virtualMachines",
3172
+ "Microsoft.Compute/virtualMachineScaleSets",
3173
+ "Microsoft.ContainerService/managedClusters",
3174
+ "Microsoft.ContainerInstance/containerGroups",
3175
+ "Microsoft.Web/sites",
3176
+ // network edge
3177
+ "Microsoft.Network/publicIPAddresses",
3178
+ "Microsoft.Network/loadBalancers",
3179
+ "Microsoft.Network/applicationGateways",
3180
+ "Microsoft.Network/natGateways",
3181
+ "Microsoft.Network/dnsZones",
3182
+ // data stores
3183
+ "Microsoft.Storage/storageAccounts",
3184
+ "Microsoft.Sql/servers",
3185
+ "Microsoft.DocumentDB/databaseAccounts",
3186
+ "Microsoft.Cache/redis",
3187
+ "Microsoft.DBforPostgreSQL/flexibleServers",
3188
+ // registry / identity edge
3189
+ "Microsoft.ContainerRegistry/registries"
3190
+ ]);
3191
+ var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
3192
+ "Microsoft.Network/virtualNetworks",
3193
+ "Microsoft.Network/virtualNetworks_subnets",
3194
+ "Microsoft.Network/networkSecurityGroups",
3195
+ "Microsoft.Network/routeTables",
3196
+ "Microsoft.Network/networkInterfaces",
3197
+ // identity/authorization hubs — a role assignment or managed identity is
3198
+ // referenced by half the estate, so contracting through one invents edges
3199
+ // between unrelated resources.
3200
+ "Microsoft.Authorization/roleAssignments",
3201
+ "Microsoft.ManagedIdentity/userAssignedIdentities",
3202
+ "Microsoft.KeyVault/vaults"
3203
+ ]);
3204
+ var VNET_KIND = "Microsoft.Network/virtualNetworks";
3205
+ var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
3206
+ var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
3207
+ var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
3208
+ var QUOTED_RE = /'([^']*)'/g;
3209
+ function resourceIdNames(value) {
3210
+ if (typeof value !== "string" || !value.includes("resourceId(")) return [];
3211
+ const out = [];
3212
+ for (const call of value.matchAll(RESOURCE_ID_RE)) {
3213
+ const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
3214
+ if (args.length >= 2) out.push(args[args.length - 1]);
3215
+ }
3216
+ return out;
3217
+ }
3218
+ function collectAzureRefs(value, out) {
2715
3219
  if (value === null || typeof value !== "object") {
2716
3220
  for (const name of resourceIdNames(value)) out.add(name);
2717
3221
  return;
@@ -3081,7 +3585,7 @@ function projectK8sLogical(ir, env, boundContext) {
3081
3585
  function releaseBoxTitle(chartName) {
3082
3586
  return `release ${chartName}`;
3083
3587
  }
3084
- function chartNameOf2(node) {
3588
+ function chartNameOf3(node) {
3085
3589
  const name = node.attrs?.name;
3086
3590
  return typeof name === "string" && name.length > 0 ? name : node.id;
3087
3591
  }
@@ -3102,7 +3606,7 @@ function projectHelmLogical(ir) {
3102
3606
  };
3103
3607
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
3104
3608
  for (const chart of charts) {
3105
- const box = releaseBoxTitle(chartNameOf2(chart));
3609
+ const box = releaseBoxTitle(chartNameOf3(chart));
3106
3610
  child(box, chart.id);
3107
3611
  const chartDir = dirOf(chart);
3108
3612
  if (chartDir === "") continue;
@@ -3119,7 +3623,7 @@ function projectHelmLogical(ir) {
3119
3623
 
3120
3624
  // src/logical-kustomize.ts
3121
3625
  import { existsSync as existsSync5 } from "node:fs";
3122
- import { join as join6 } from "node:path";
3626
+ import { join as join8 } from "node:path";
3123
3627
  function overlayBoxTitle(name) {
3124
3628
  return `overlay ${name}`;
3125
3629
  }
@@ -3139,7 +3643,7 @@ function kustomizationRoot(bases, dir, exists) {
3139
3643
  let current = dir;
3140
3644
  for (let i = 0; i < 4 && current; i++) {
3141
3645
  for (const base of bases) {
3142
- if (exists(join6(base, current, "kustomization.yaml")) || exists(join6(base, current, "kustomization.yml"))) {
3646
+ if (exists(join8(base, current, "kustomization.yaml")) || exists(join8(base, current, "kustomization.yml"))) {
3143
3647
  return current;
3144
3648
  }
3145
3649
  }
@@ -3715,93 +4219,16 @@ function addCompositeDepsCounted(ir, dag) {
3715
4219
  return { ir: out, attached: out.edges.length - before };
3716
4220
  }
3717
4221
 
3718
- // src/zoom-notes.ts
3719
- function zoomNote(zoom, ir, compositeEdgesAttached) {
3720
- const empty = ir.nodes.length === 0;
3721
- if (zoom === "components") {
3722
- return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
3723
- }
3724
- if (zoom === "logical") {
3725
- return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
3726
- }
3727
- if (zoom === "composites") {
3728
- return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
3729
- }
3730
- if (zoom === "runtime") {
3731
- const children = ir.nodes.filter((n) => n.runtimeOwner).length;
3732
- return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
3733
- }
3734
- return void 0;
3735
- }
3736
- function logicalKept(before, after) {
3737
- if (after > 0 && after * 3 >= before) return void 0;
3738
- 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)`;
3739
- }
3740
- function edgelessNote(zoom, ir, detail) {
3741
- if (zoom === "components" || zoom === "logical") return void 0;
3742
- if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
3743
- if (detail !== void 0 && detail < 3) {
3744
- 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)";
3745
- }
3746
- return "no edges \u2014 nothing in this estate references anything else";
3747
- }
3748
- var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
3749
- "K8s::Core::Namespace",
3750
- "K8s::Rbac::ClusterRole",
3751
- "K8s::Rbac::ClusterRoleBinding",
3752
- "K8s::Storage::StorageClass",
3753
- "K8s::Apiextensions::CustomResourceDefinition"
3754
- ]);
3755
- function namespaceMismatchNote(nodes) {
3756
- const k8s = nodes.filter((n) => n?.lexicon === "k8s");
3757
- if (k8s.length === 0) return void 0;
3758
- const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
3759
- if (pending.length < 2) return void 0;
3760
- if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
3761
- if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
3762
- 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`;
3763
- }
3764
- function namespaceJoinNote(joined) {
3765
- if (joined.length === 0) return void 0;
3766
- const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
3767
- return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
3768
- }
3769
- function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
3770
- const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
3771
- const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
3772
- return notes.length ? notes.join(" \xB7 ") : void 0;
3773
- }
3774
- function tierMismatchNote(ir, tiers, currentTier) {
3775
- const values = tiers?.values ?? [];
3776
- if (values.length < 2) return void 0;
3777
- const byLexicon = /* @__PURE__ */ new Map();
3778
- for (const n of ir.nodes) {
3779
- const status = n.attrs?._status;
3780
- if (status !== "good" && status !== "accent") continue;
3781
- const lexicon = n.lexicon ?? "?";
3782
- const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
3783
- c[status === "good" ? "good" : "accent"]++;
3784
- byLexicon.set(lexicon, c);
3785
- }
3786
- for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
3787
- if (accent < 2 || accent * 2 < good + accent) continue;
3788
- const here = currentTier ? `tier "${currentTier}"` : "the default tier";
3789
- const others = values.filter((v) => v !== currentTier).join(", ");
3790
- 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)`;
3791
- }
3792
- return void 0;
3793
- }
3794
-
3795
- // src/resources.ts
3796
- var NON_RESOURCE_KINDS = /* @__PURE__ */ new Set(["AWS::CloudFormation::Parameter", "chant:output"]);
3797
- function nonResourceEntities(ir) {
3798
- const out = /* @__PURE__ */ new Set();
3799
- for (const n of ir.nodes) if (NON_RESOURCE_KINDS.has(n.kind)) out.add(n.id);
3800
- return out;
3801
- }
3802
- function componentSegment(parts, known) {
3803
- if (known) {
3804
- return parts.slice(0, -1).find((p) => known.has(p));
4222
+ // src/resources.ts
4223
+ var NON_RESOURCE_KINDS = /* @__PURE__ */ new Set(["AWS::CloudFormation::Parameter", "chant:output"]);
4224
+ function nonResourceEntities(ir) {
4225
+ const out = /* @__PURE__ */ new Set();
4226
+ for (const n of ir.nodes) if (NON_RESOURCE_KINDS.has(n.kind)) out.add(n.id);
4227
+ return out;
4228
+ }
4229
+ function componentSegment(parts, known) {
4230
+ if (known) {
4231
+ return parts.slice(0, -1).find((p) => known.has(p));
3805
4232
  }
3806
4233
  return parts[0] === "src" && parts.length >= 3 ? parts[1] : void 0;
3807
4234
  }
@@ -3823,6 +4250,19 @@ function resourcesByComponent(ir, known) {
3823
4250
  }
3824
4251
 
3825
4252
  // src/reconcile.ts
4253
+ var DISRUPTION_SEVERITY = {
4254
+ "in-place": 0,
4255
+ rolling: 1,
4256
+ unknown: 1,
4257
+ replace: 2,
4258
+ destroy: 3
4259
+ };
4260
+ var DISRUPTION_LEVELS = ["in-place", "rolling", "replace", "destroy", "unknown"];
4261
+ function worstDisruption(a, b) {
4262
+ if (!a) return b;
4263
+ if (!b) return a;
4264
+ return DISRUPTION_SEVERITY[b] > DISRUPTION_SEVERITY[a] ? b : a;
4265
+ }
3826
4266
  function summarizePlan(plan, byComponent, nonResource) {
3827
4267
  const componentByEntity = /* @__PURE__ */ new Map();
3828
4268
  for (const [component, resources] of Object.entries(byComponent)) {
@@ -3831,6 +4271,9 @@ function summarizePlan(plan, byComponent, nonResource) {
3831
4271
  const counts = {};
3832
4272
  const unobservedCounts = {};
3833
4273
  const runtimeCounts = {};
4274
+ const disruption = Object.fromEntries(DISRUPTION_LEVELS.map((l) => [l, 0]));
4275
+ const disruptionByComponent = {};
4276
+ let disruptionUncorrelated;
3834
4277
  let uncorrelated = 0;
3835
4278
  let unobservedUncorrelated = 0;
3836
4279
  let runtimeUncorrelated = 0;
@@ -3856,6 +4299,12 @@ function summarizePlan(plan, byComponent, nonResource) {
3856
4299
  total++;
3857
4300
  if (component) counts[component] = (counts[component] ?? 0) + 1;
3858
4301
  else uncorrelated++;
4302
+ const level = entry.disruption;
4303
+ if (!level) continue;
4304
+ if (!DISRUPTION_LEVELS.includes(level)) continue;
4305
+ disruption[level]++;
4306
+ if (component) disruptionByComponent[component] = worstDisruption(disruptionByComponent[component], level);
4307
+ else disruptionUncorrelated = worstDisruption(disruptionUncorrelated, level);
3859
4308
  }
3860
4309
  return {
3861
4310
  env: plan.env,
@@ -3867,7 +4316,10 @@ function summarizePlan(plan, byComponent, nonResource) {
3867
4316
  unobservedUncorrelated,
3868
4317
  runtime,
3869
4318
  runtimeByComponent: runtimeCounts,
3870
- runtimeUncorrelated
4319
+ runtimeUncorrelated,
4320
+ disruption,
4321
+ disruptionByComponent,
4322
+ ...disruptionUncorrelated ? { disruptionUncorrelated } : {}
3871
4323
  };
3872
4324
  }
3873
4325
 
@@ -3883,10 +4335,10 @@ import {
3883
4335
 
3884
4336
  // src/icon-packs.ts
3885
4337
  import { readFileSync as readFileSync6 } from "node:fs";
3886
- import { dirname as dirname3, join as join7 } from "node:path";
4338
+ import { dirname as dirname3, join as join9 } from "node:path";
3887
4339
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3888
- var ICON_ROOT = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
3889
- var cache = /* @__PURE__ */ new Map();
4340
+ var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
4341
+ var cache2 = /* @__PURE__ */ new Map();
3890
4342
  function unprefix(body) {
3891
4343
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
3892
4344
  }
@@ -3905,9 +4357,9 @@ function plate(glyph) {
3905
4357
  return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
3906
4358
  }
3907
4359
  function loadIcon(rel) {
3908
- const hit = cache.get(rel);
4360
+ const hit = cache2.get(rel);
3909
4361
  if (hit) return hit;
3910
- const raw = readFileSync6(join7(ICON_ROOT, `${rel}.svg`), "utf8");
4362
+ const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
3911
4363
  const open = raw.indexOf("<svg");
3912
4364
  const openEnd = raw.indexOf(">", open);
3913
4365
  const close = raw.lastIndexOf("</svg");
@@ -3916,7 +4368,7 @@ function loadIcon(rel) {
3916
4368
  if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
3917
4369
  const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3918
4370
  const glyph = PLATED.has(rel) ? plate(bare) : bare;
3919
- cache.set(rel, glyph);
4371
+ cache2.set(rel, glyph);
3920
4372
  return glyph;
3921
4373
  }
3922
4374
  var K8S_ICON_BY_KIND = {
@@ -4161,10 +4613,646 @@ function carveNote(report, ir) {
4161
4613
  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.`;
4162
4614
  }
4163
4615
 
4616
+ // src/carve-manifest.ts
4617
+ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
4618
+ import { dirname as dirname4, join as join10 } from "node:path";
4619
+ var CARVE_MANIFEST_SUFFIX = ".carve.json";
4620
+ 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.";
4621
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4622
+ var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
4623
+ function parseCarveManifest(value) {
4624
+ if (!isRecord2(value)) return null;
4625
+ if (value.version !== 1) return null;
4626
+ if (typeof value.target !== "string" || !value.target) return null;
4627
+ return value;
4628
+ }
4629
+ function readCarveManifest(path, readFile = (p) => readFileSync7(p, "utf8")) {
4630
+ let text;
4631
+ try {
4632
+ text = readFile(path);
4633
+ } catch {
4634
+ return null;
4635
+ }
4636
+ try {
4637
+ return parseCarveManifest(JSON.parse(text));
4638
+ } catch {
4639
+ return null;
4640
+ }
4641
+ }
4642
+ function carveStageOf(m) {
4643
+ if (m.apply) return "applied";
4644
+ if (m.bridge) return "bridged";
4645
+ return "emitted";
4646
+ }
4647
+ function carveWordFor(stage) {
4648
+ switch (stage) {
4649
+ case "applied":
4650
+ return "carved \u2192 chant";
4651
+ case "bridged":
4652
+ return "bridged \u2014 apply is yours";
4653
+ default:
4654
+ return "emitted \u2014 not bridged";
4655
+ }
4656
+ }
4657
+ function carveToneFor(stage) {
4658
+ return stage === "applied" ? "good" : "accent";
4659
+ }
4660
+ function carveStageNote(state) {
4661
+ switch (state.stage) {
4662
+ case "applied":
4663
+ 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.";
4664
+ case "bridged":
4665
+ return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
4666
+ default:
4667
+ return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
4668
+ }
4669
+ }
4670
+ function carveStateOf(m, path) {
4671
+ const stage = carveStageOf(m);
4672
+ const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
4673
+ return {
4674
+ target: m.target,
4675
+ ...m.tfType ? { tfType: m.tfType } : {},
4676
+ stage,
4677
+ graduated: stage === "applied",
4678
+ // The last stamped step wins — that is the age of the state, not the age
4679
+ // of the carve.
4680
+ ...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
4681
+ files: strings(m.emit?.files),
4682
+ excised: strings(m.bridge?.excised),
4683
+ deferredInputs: deferred,
4684
+ ...m.apply ? {
4685
+ ownership: {
4686
+ ...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
4687
+ ...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
4688
+ ...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
4689
+ }
4690
+ } : {},
4691
+ ...m.from ? { from: m.from } : {},
4692
+ outDir: dirname4(path),
4693
+ path
4694
+ };
4695
+ }
4696
+ var nodeCarveIo = {
4697
+ readdir: (dir) => readdirSync4(dir),
4698
+ isDirectory: (p) => {
4699
+ try {
4700
+ return statSync4(p).isDirectory();
4701
+ } catch {
4702
+ return false;
4703
+ }
4704
+ },
4705
+ readFile: (p) => readFileSync7(p, "utf8")
4706
+ };
4707
+ var MANIFEST_SCAN_DEPTH = 2;
4708
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
4709
+ function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
4710
+ const out = [];
4711
+ const walk = (d, level) => {
4712
+ let entries;
4713
+ try {
4714
+ entries = io.readdir(d).sort();
4715
+ } catch {
4716
+ return;
4717
+ }
4718
+ for (const name of entries) {
4719
+ const full = join10(d, name);
4720
+ if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
4721
+ out.push(full);
4722
+ continue;
4723
+ }
4724
+ if (level >= depth) continue;
4725
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
4726
+ if (io.isDirectory(full)) walk(full, level + 1);
4727
+ }
4728
+ };
4729
+ walk(dir, 0);
4730
+ return out;
4731
+ }
4732
+ function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
4733
+ const rank = { emitted: 0, bridged: 1, applied: 2 };
4734
+ const byTarget = /* @__PURE__ */ new Map();
4735
+ for (const dir of dirs) {
4736
+ for (const path of listCarveManifests(dir, io, depth)) {
4737
+ const manifest = readCarveManifest(path, io.readFile);
4738
+ if (!manifest) continue;
4739
+ const state = carveStateOf(manifest, path);
4740
+ const held = byTarget.get(state.target);
4741
+ if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
4742
+ }
4743
+ }
4744
+ return byTarget;
4745
+ }
4746
+ function carveProgress(states, total) {
4747
+ let applied = 0;
4748
+ let bridged = 0;
4749
+ let emitted = 0;
4750
+ for (const s of states) {
4751
+ if (s.stage === "applied") applied++;
4752
+ else if (s.stage === "bridged") bridged++;
4753
+ else emitted++;
4754
+ }
4755
+ const inFlight = bridged + emitted;
4756
+ const parts = [];
4757
+ if (emitted) parts.push(`${emitted} emitted`);
4758
+ if (bridged) parts.push(`${bridged} bridged`);
4759
+ return {
4760
+ applied,
4761
+ bridged,
4762
+ emitted,
4763
+ inFlight,
4764
+ total: typeof total === "number" ? total : null,
4765
+ label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
4766
+ detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
4767
+ };
4768
+ }
4769
+ function applyCommandFor(state, labels = {}) {
4770
+ const env = state.ownership?.env ?? "<env>";
4771
+ const stack = state.ownership?.stack ?? "<stack>";
4772
+ const from = labels.from ?? state.from ?? "<terraform-dir>";
4773
+ const out = labels.out ?? state.outDir;
4774
+ return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
4775
+ }
4776
+ function carveStatePayload(states, total, labels = () => ({})) {
4777
+ const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
4778
+ return {
4779
+ manifests: list3.length,
4780
+ progress: carveProgress(list3, total),
4781
+ states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
4782
+ apply: { human: true, note: APPLY_IS_HUMAN }
4783
+ };
4784
+ }
4785
+ function splitCarveState(tfIr, states, opts = {}) {
4786
+ if (states.size === 0) return { tf: tfIr, graduated: [] };
4787
+ const shorten = opts.shorten ?? ((p) => p);
4788
+ const source = (files) => files.map(shorten).join(", ");
4789
+ const gone = /* @__PURE__ */ new Set();
4790
+ const graduated = [];
4791
+ const nodes = [];
4792
+ for (const node of tfIr.nodes) {
4793
+ const state = states.get(node.id);
4794
+ if (!state) {
4795
+ nodes.push(node);
4796
+ continue;
4797
+ }
4798
+ if (state.graduated) {
4799
+ gone.add(node.id);
4800
+ graduated.push({
4801
+ ...node,
4802
+ attrs: {
4803
+ ...node.attrs,
4804
+ _status: "good",
4805
+ carve: carveWordFor(state.stage),
4806
+ carveState: state.stage,
4807
+ ...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
4808
+ ...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
4809
+ ...state.at ? { carvedAt: state.at } : {},
4810
+ ...state.files.length ? { chantSource: source(state.files) } : {}
4811
+ }
4812
+ });
4813
+ continue;
4814
+ }
4815
+ nodes.push({
4816
+ ...node,
4817
+ attrs: {
4818
+ ...node.attrs,
4819
+ _status: carveToneFor(state.stage),
4820
+ carve: carveWordFor(state.stage),
4821
+ carveState: state.stage,
4822
+ ...state.at ? { carvedAt: state.at } : {},
4823
+ ...state.files.length ? { chantSource: source(state.files) } : {},
4824
+ ...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
4825
+ ...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
4826
+ }
4827
+ });
4828
+ }
4829
+ const byStack = {};
4830
+ for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
4831
+ const left = members.filter((id) => !gone.has(id));
4832
+ if (left.length) byStack[band] = left;
4833
+ }
4834
+ return {
4835
+ tf: {
4836
+ nodes,
4837
+ edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
4838
+ groups: { ...tfIr.groups, byStack }
4839
+ },
4840
+ graduated
4841
+ };
4842
+ }
4843
+ var GRADUATED_BAND = "carved \u2192 chant";
4844
+ function bandGraduated(tf, graduated) {
4845
+ if (graduated.length === 0) return tf;
4846
+ return {
4847
+ nodes: [...graduated, ...tf.nodes],
4848
+ edges: tf.edges,
4849
+ groups: {
4850
+ ...tf.groups,
4851
+ byStack: {
4852
+ [GRADUATED_BAND]: graduated.map((n) => n.id),
4853
+ ...tf.groups?.byStack ?? {}
4854
+ }
4855
+ }
4856
+ };
4857
+ }
4858
+ function carveStateNote(progress, states) {
4859
+ if (states.size === 0) return "";
4860
+ const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
4861
+ return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
4862
+ }
4863
+
4864
+ // src/ops-lens.ts
4865
+ var SUPPORTED_MAJOR2 = 1;
4866
+ var refuse2 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
4867
+ 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.";
4868
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4869
+ function parseOpIr(value) {
4870
+ if (!isRecord3(value)) {
4871
+ return refuse2("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
4872
+ }
4873
+ if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
4874
+ return refuse2(
4875
+ "That JSON doesn't look like an op.json \u2014 it needs a string `name` and a `phases` array.",
4876
+ HOW_TO_EMIT
4877
+ );
4878
+ }
4879
+ const bad = value.phases.findIndex((p) => !isRecord3(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
4880
+ if (bad >= 0) {
4881
+ return refuse2(
4882
+ `That JSON has a \`phases\` array, but entry ${bad} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
4883
+ HOW_TO_EMIT
4884
+ );
4885
+ }
4886
+ const version = value.formatVersion;
4887
+ if (version !== void 0) {
4888
+ const major = Number(String(version).split(".")[0]);
4889
+ if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR2) {
4890
+ return refuse2(
4891
+ `This op.json declares format version ${String(version)}; behold reads version ${SUPPORTED_MAJOR2}.`,
4892
+ "Upgrade behold, or render the Op with a behold that speaks its version."
4893
+ );
4894
+ }
4895
+ }
4896
+ return { ok: true, ir: value };
4897
+ }
4898
+ function readOpIr(path, readFile) {
4899
+ let text;
4900
+ try {
4901
+ text = readFile(path);
4902
+ } catch (err) {
4903
+ return refuse2(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
4904
+ }
4905
+ let json;
4906
+ try {
4907
+ json = JSON.parse(text);
4908
+ } catch (err) {
4909
+ return refuse2(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
4910
+ }
4911
+ return parseOpIr(json);
4912
+ }
4913
+ function receiptName(step) {
4914
+ const r = step.receipt;
4915
+ if (typeof r === "string") return r;
4916
+ return r && typeof r.name === "string" && r.name || "effect";
4917
+ }
4918
+ function stepLabel(step) {
4919
+ if (step.kind === "gate") return `gate:${step.signalName}`;
4920
+ if (step.kind === "effect") return `effect:${receiptName(step)}`;
4921
+ return step.fn;
4922
+ }
4923
+ function stepKind(step) {
4924
+ if (step.kind === "gate") return "Gate";
4925
+ if (step.kind === "effect") return "Effect";
4926
+ return step.fn;
4927
+ }
4928
+ function stepStatus(step) {
4929
+ return step.kind === "gate" ? "warn" : step.kind === "effect" ? "accent" : "neutral";
4930
+ }
4931
+ function argsLine(args) {
4932
+ const entries = Object.entries(args ?? {});
4933
+ if (!entries.length) return void 0;
4934
+ return entries.map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`).join(", ");
4935
+ }
4936
+ function profileLine(p) {
4937
+ if (!p) return void 0;
4938
+ const parts = [];
4939
+ if (p.startToCloseTimeout) parts.push(`${p.startToCloseTimeout} start-to-close`);
4940
+ if (p.heartbeatTimeout) parts.push(`${p.heartbeatTimeout} heartbeat`);
4941
+ if (p.retry?.maximumAttempts) parts.push(`${p.retry.maximumAttempts} attempts`);
4942
+ if (p.retry?.initialInterval) parts.push(`from ${p.retry.initialInterval}`);
4943
+ return parts.length ? parts.join(", ") : void 0;
4944
+ }
4945
+ function placeSteps(op) {
4946
+ const out = [];
4947
+ const lists = [
4948
+ [op.phases ?? [], "phases"],
4949
+ [op.onFailure ?? [], "onFailure"]
4950
+ ];
4951
+ const used = /* @__PURE__ */ new Set();
4952
+ for (const [phases, when] of lists) {
4953
+ for (const phase of phases) {
4954
+ const box = when === "onFailure" ? `${op.name} \xB7 onFailure: ${phase.name}` : `${op.name} \xB7 ${phase.name}`;
4955
+ for (const step of phase.steps ?? []) {
4956
+ let id = `${op.name}/${phase.name}/${stepLabel(step)}`;
4957
+ for (let n = 2; used.has(id); n += 1) id = `${op.name}/${phase.name}/${stepLabel(step)}#${n}`;
4958
+ used.add(id);
4959
+ out.push({ id, step, phase, when, box });
4960
+ }
4961
+ }
4962
+ }
4963
+ return out;
4964
+ }
4965
+ function opPlacedSteps(op) {
4966
+ return placeSteps(op).map((p) => ({
4967
+ id: p.id,
4968
+ step: p.step,
4969
+ phase: p.phase.name,
4970
+ parallel: Boolean(p.phase.parallel),
4971
+ when: p.when
4972
+ }));
4973
+ }
4974
+ var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
4975
+ var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
4976
+ var isPredicate = (v) => isRecord3(v) && typeof v.kind === "string";
4977
+ function isConvergeRule(v) {
4978
+ if (!isRecord3(v)) return false;
4979
+ if (typeof v.id !== "string" || !v.id) return false;
4980
+ if (typeof v.why !== "string" || !v.why) return false;
4981
+ if (!isPredicate(v.when)) return false;
4982
+ const then = v.then;
4983
+ if (!isRecord3(then)) return false;
4984
+ return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
4985
+ }
4986
+ function convergeTable(op) {
4987
+ for (const placed of placeSteps(op)) {
4988
+ const { step } = placed;
4989
+ if (step.kind !== "activity" || step.fn !== "convergeTick") continue;
4990
+ const rules = step.args?.rules;
4991
+ if (!Array.isArray(rules)) return void 0;
4992
+ const kept = rules.filter(isConvergeRule);
4993
+ if (!kept.length) return void 0;
4994
+ return {
4995
+ tickStepId: placed.id,
4996
+ phase: placed.phase.name,
4997
+ rules: kept,
4998
+ ...typeof step.args?.dial === "string" ? { dial: step.args.dial } : {},
4999
+ ...typeof step.args?.budget === "number" ? { budget: step.args.budget } : {},
5000
+ ...typeof step.args?.env === "string" ? { env: step.args.env } : {}
5001
+ };
5002
+ }
5003
+ return void 0;
5004
+ }
5005
+ var COMPARISON_TEXT = {
5006
+ eq: "=",
5007
+ neq: "!=",
5008
+ gt: ">",
5009
+ gte: ">=",
5010
+ lt: "<",
5011
+ lte: "<="
5012
+ };
5013
+ function predicateText(predicate) {
5014
+ switch (predicate.kind) {
5015
+ case "field-comparison": {
5016
+ const op = COMPARISON_TEXT[predicate.op];
5017
+ if (!op) return `(unreadable comparison on ${predicate.field})`;
5018
+ const value = typeof predicate.value === "string" ? JSON.stringify(predicate.value) : String(predicate.value);
5019
+ return `${predicate.field} ${op} ${value}`;
5020
+ }
5021
+ case "field-truthiness":
5022
+ return `${predicate.field} is ${predicate.op}`;
5023
+ case "all-of":
5024
+ case "any-of": {
5025
+ const joiner = predicate.kind === "all-of" ? " and " : " or ";
5026
+ const parts = (predicate.predicates ?? []).filter(isPredicate);
5027
+ if (!parts.length) return predicate.kind === "all-of" ? "(always)" : "(never)";
5028
+ return parts.map((p) => p.kind === "all-of" || p.kind === "any-of" ? `(${predicateText(p)})` : predicateText(p)).join(joiner);
5029
+ }
5030
+ default:
5031
+ return "(predicate outside the kinds behold reads)";
5032
+ }
5033
+ }
5034
+ function actionText(action) {
5035
+ return action.kind === "run" ? `run(${action.op})` : `report: ${action.reason}`;
5036
+ }
5037
+ function ruleStatus(rule, declared) {
5038
+ if (rule.then.kind !== "run") return "neutral";
5039
+ return declared.has(rule.then.op) ? "accent" : "warn";
5040
+ }
5041
+ function ruleNode(op, table, rule, declared) {
5042
+ const dispatches = rule.then.kind === "run" && declared.has(rule.then.op) ? rule.then.op : void 0;
5043
+ const dangling = rule.then.kind === "run" && !declared.has(rule.then.op) ? rule.then.op : void 0;
5044
+ return {
5045
+ id: ruleId(op.name, rule),
5046
+ // The card's kind, as an activity's is its `fn`: a converge rule is what
5047
+ // this is, and the whole table reads as one kind of thing in the track.
5048
+ kind: "ConvergeRule",
5049
+ lexicon: "op",
5050
+ attrs: {
5051
+ _status: ruleStatus(rule, declared),
5052
+ _step: "rule",
5053
+ rule: rule.id,
5054
+ op: op.name,
5055
+ phase: table.phase,
5056
+ // The condition, the action, and the rationale — the three columns a rule
5057
+ // table has. `why` is chant's required field, verbatim: the accessible-ops
5058
+ // promise is that the answer to "why did it do that" is already written
5059
+ // down, so behold's job is to show it, not to summarise it.
5060
+ when: predicateText(rule.when),
5061
+ then: actionText(rule.then),
5062
+ why: rule.why,
5063
+ ...dispatches ? { dispatches } : {},
5064
+ // A `then` naming an Op that isn't in this set: stated, not guessed at.
5065
+ // chant's TMP014 refuses a run() naming an Op the PROJECT doesn't declare,
5066
+ // so this is behold's own composition boundary showing through, and saying
5067
+ // which one it is beats an edge into nowhere.
5068
+ ...dangling ? { dangling: `${dangling} \u2014 not among the Ops rendered here, so no edge is drawn` } : {},
5069
+ // `flapThreshold` absent means chant's own default applies. Named the way
5070
+ // a gate's unset timeout is, rather than restating a number chant owns.
5071
+ flap: rule.flapThreshold !== void 0 ? `${rule.flapThreshold} consecutive ticks` : "(chant default)",
5072
+ ...table.dial ? { dial: table.dial } : {}
5073
+ }
5074
+ };
5075
+ }
5076
+ function nodeFor(op, placed, guards) {
5077
+ const { step, phase, when } = placed;
5078
+ const common = {
5079
+ op: op.name,
5080
+ phase: phase.name,
5081
+ ...phase.parallel ? { parallel: true } : {},
5082
+ ...when === "onFailure" ? { when: "onFailure" } : {}
5083
+ };
5084
+ if (step.kind === "gate") {
5085
+ return {
5086
+ id: placed.id,
5087
+ kind: stepKind(step),
5088
+ lexicon: "op",
5089
+ attrs: {
5090
+ _status: stepStatus(step),
5091
+ _step: "gate",
5092
+ gate: step.signalName,
5093
+ timeout: step.timeout ?? "(chant default)",
5094
+ ...common,
5095
+ ...step.description ? { description: step.description } : {},
5096
+ // "gates drawn as their own marks on the step they guard" (#284): the
5097
+ // gate names what it holds back, so the pause reads from the gate card
5098
+ // without hunting for the next one in the track.
5099
+ ...guards ? { guards } : {}
5100
+ }
5101
+ };
5102
+ }
5103
+ if (step.kind === "effect") {
5104
+ return {
5105
+ id: placed.id,
5106
+ kind: stepKind(step),
5107
+ lexicon: "op",
5108
+ attrs: {
5109
+ _status: stepStatus(step),
5110
+ _step: "effect",
5111
+ effect: receiptName(step),
5112
+ ...common,
5113
+ ...step.expectation !== void 0 ? { expectation: String(step.expectation) } : {},
5114
+ ...step.description ? { description: step.description } : {},
5115
+ // The nested steps, named but not drawn — see OpIrEffectStep's doc.
5116
+ ...step.steps?.length ? { runs: step.steps.map(stepLabel).join(" \u2192 ") } : {}
5117
+ }
5118
+ };
5119
+ }
5120
+ const profile = step.profile;
5121
+ const policy = profileLine(profile ? op.activityProfiles?.[profile] : void 0);
5122
+ const args = argsLine(step.args);
5123
+ return {
5124
+ id: placed.id,
5125
+ kind: stepKind(step),
5126
+ lexicon: "op",
5127
+ attrs: {
5128
+ _status: stepStatus(step),
5129
+ _step: "activity",
5130
+ fn: step.fn,
5131
+ ...common,
5132
+ ...profile ? { profile } : {},
5133
+ ...policy ? { policy } : {},
5134
+ // The resolved args, verbatim from the IR. They carry the Op's SCOPE
5135
+ // (env, stack, template path) and never an estate entity id — see the
5136
+ // module doc on why no edge is drawn from them.
5137
+ ...args ? { args } : {},
5138
+ ...step.outcomeAttribute ? { outcome: step.outcomeAttribute.from ? `${step.outcomeAttribute.name} \u2190 ${step.outcomeAttribute.from}` : step.outcomeAttribute.name } : {},
5139
+ ...op.activityContracts?.[step.fn] ? { contract: "declared (args/returns JSON Schema in op.json)" } : {}
5140
+ }
5141
+ };
5142
+ }
5143
+ function chainPhases(placed, when) {
5144
+ const byPhase = [];
5145
+ for (const p of placed) {
5146
+ if (p.when !== when) continue;
5147
+ const last = byPhase[byPhase.length - 1];
5148
+ if (last && last.phase === p.phase) last.ids.push(p.id);
5149
+ else byPhase.push({ phase: p.phase, ids: [p.id] });
5150
+ }
5151
+ return byPhase.map(
5152
+ ({ phase, ids }) => phase.parallel ? { heads: ids, tails: ids } : { heads: ids.slice(0, 1), tails: ids.slice(-1) }
5153
+ );
5154
+ }
5155
+ function opsToIr(ops) {
5156
+ const nodes = [];
5157
+ const edges = [];
5158
+ const byStack = {};
5159
+ const endpoints = /* @__PURE__ */ new Map();
5160
+ const declared = new Set(ops.map((o) => o.name));
5161
+ const dispatches = [];
5162
+ for (const op of ops) {
5163
+ const placed = placeSteps(op);
5164
+ for (let i = 0; i < placed.length; i += 1) {
5165
+ const p = placed[i];
5166
+ const next = placed[i + 1];
5167
+ const guards = p.step.kind === "gate" && next && next.when === p.when ? stepLabel(next.step) : void 0;
5168
+ nodes.push(nodeFor(op, p, guards));
5169
+ (byStack[p.box] ??= []).push(p.id);
5170
+ }
5171
+ for (const when of ["phases", "onFailure"]) {
5172
+ const track = chainPhases(placed, when);
5173
+ for (let i = 0; i + 1 < track.length; i += 1) {
5174
+ for (const from of track[i].tails) {
5175
+ for (const to of track[i + 1].heads) edges.push({ from, to, kind: "ref", viaAttr: "then" });
5176
+ }
5177
+ }
5178
+ if (when === "phases" && track.length) {
5179
+ endpoints.set(op.name, { first: track[0].heads, last: track[track.length - 1].tails });
5180
+ }
5181
+ }
5182
+ const table = convergeTable(op);
5183
+ if (!table) continue;
5184
+ for (const rule of table.rules) {
5185
+ const node = ruleNode(op, table, rule, declared);
5186
+ nodes.push(node);
5187
+ (byStack[RULE_BOX(op.name)] ??= []).push(node.id);
5188
+ edges.push({ from: table.tickStepId, to: node.id, kind: "ref", viaAttr: "rule" });
5189
+ if (rule.then.kind === "run") dispatches.push({ from: node.id, op: rule.then.op });
5190
+ }
5191
+ }
5192
+ for (const op of ops) {
5193
+ const mine = endpoints.get(op.name);
5194
+ if (!mine) continue;
5195
+ for (const dep of op.depends ?? []) {
5196
+ const theirs = endpoints.get(dep);
5197
+ if (!theirs) continue;
5198
+ for (const from of theirs.last) {
5199
+ for (const to of mine.first) edges.push({ from, to, kind: "ref", viaAttr: "depends" });
5200
+ }
5201
+ }
5202
+ }
5203
+ for (const { from, op } of dispatches) {
5204
+ const target = endpoints.get(op);
5205
+ if (!target) continue;
5206
+ for (const to of target.first) edges.push({ from, to, kind: "ref", viaAttr: "run" });
5207
+ }
5208
+ return { nodes, edges, groups: { byStack } };
5209
+ }
5210
+ function opCardFields(node) {
5211
+ const step = node.attrs._step;
5212
+ const phase = typeof node.attrs.phase === "string" ? node.attrs.phase : void 0;
5213
+ const declared = step === "gate" ? [
5214
+ { label: "gate", value: String(node.attrs.gate ?? "") },
5215
+ { label: "timeout", value: String(node.attrs.timeout ?? "") }
5216
+ ] : step === "effect" ? [
5217
+ { label: "effect", value: String(node.attrs.effect ?? "") },
5218
+ ...phase ? [{ label: "phase", value: phase }] : []
5219
+ ] : step === "activity" ? [
5220
+ ...phase ? [{ label: "phase", value: phase }] : [],
5221
+ ...typeof node.attrs.profile === "string" ? [{ label: "profile", value: node.attrs.profile }] : []
5222
+ ] : (
5223
+ // A converge rule card leads with the two columns that make it a
5224
+ // rule — the condition and what it does about it. `why` is the long
5225
+ // one and belongs in the inspect pane, where it reads in full
5226
+ // rather than being cut to fit a card.
5227
+ step === "rule" ? [
5228
+ { label: "when", value: String(node.attrs.when ?? "") },
5229
+ { label: "then", value: String(node.attrs.then ?? "") }
5230
+ ] : void 0
5231
+ );
5232
+ if (!declared) return void 0;
5233
+ const run3 = typeof node.attrs.run === "string" ? node.attrs.run : void 0;
5234
+ return run3 ? [{ label: "run", value: run3 }, ...declared.slice(0, 1)] : declared;
5235
+ }
5236
+ function opsNote(ops, ir) {
5237
+ const gates = ir.nodes.filter((n) => n.attrs._step === "gate").length;
5238
+ const rules = ir.nodes.filter((n) => n.attrs._step === "rule");
5239
+ const steps = ir.nodes.length - rules.length;
5240
+ const dangling = rules.filter((n) => n.attrs.dangling !== void 0).length;
5241
+ const counts = [
5242
+ `${ops.length} declared Op${ops.length === 1 ? "" : "s"}`,
5243
+ `${steps} step${steps === 1 ? "" : "s"}`,
5244
+ ...gates ? [`${gates} gate${gates === 1 ? "" : "s"}`] : [],
5245
+ ...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : []
5246
+ ].join(" \xB7 ");
5247
+ 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.` : "") : "";
5248
+ 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.`;
5249
+ }
5250
+
4164
5251
  // src/render.ts
4165
5252
  registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
4166
5253
  registerPack({ lexicon: "helm", iconFor: helmIconFor });
4167
5254
  registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
5255
+ registerPack({ lexicon: "op", iconFor: () => void 0, fields: opCardFields });
4168
5256
  function renderArchitecture(ir, byContainer, opts = {}) {
4169
5257
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
4170
5258
  const layout = layoutArchitecture(ir, byContainer, {
@@ -4187,6 +5275,7 @@ function renderGraph(ir, opts = {}) {
4187
5275
  const layout = layoutIr(ir, { fit: true, ...boxes ? { groups: boxes } : {} });
4188
5276
  if (opts.radial && !boxes) radializeLayout(layout, groupKeyByNode(ir), footprints(ir));
4189
5277
  else if (!boxes) packComponents(layout, ir);
5278
+ else if (boxKey === "byStack" && boxes) packMemberBoxes(layout, ir, boxes);
4190
5279
  const svg = renderSvg(ir, layout, {
4191
5280
  fit: true,
4192
5281
  hideTitle: true,
@@ -4253,7 +5342,8 @@ function bandedPlan(ir, bands) {
4253
5342
  return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
4254
5343
  }
4255
5344
  function renderCarveEstate(tfIr, appIr, opts) {
4256
- const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts);
5345
+ const staged = withCarveState(tfIr, appIr, opts);
5346
+ const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle });
4257
5347
  const svg = renderSvg(comp.ir, comp.layout, {
4258
5348
  fit: true,
4259
5349
  hideTitle: true,
@@ -4262,6 +5352,17 @@ function renderCarveEstate(tfIr, appIr, opts) {
4262
5352
  });
4263
5353
  return { svg, ir: comp.ir };
4264
5354
  }
5355
+ function withCarveState(tfIr, appIr, opts) {
5356
+ const states = opts.carved ?? /* @__PURE__ */ new Map();
5357
+ const { tf, graduated } = splitCarveState(tfIr, states, { ...opts.shorten ? { shorten: opts.shorten } : {} });
5358
+ const ns = namespaceAppIr(appIr, opts.appTitle);
5359
+ const progress = carveProgress(states.values(), tfIr.nodes.length);
5360
+ return {
5361
+ tf,
5362
+ appNs: { ...ns, nodes: [...ns.nodes, ...graduated] },
5363
+ carvedTitle: states.size ? `carved so far \u2014 ${progress.label}` : "carved so far"
5364
+ };
5365
+ }
4265
5366
  function namespaceAppIr(appIr, appTitle) {
4266
5367
  const prefix = `${appTitle.split(" ")[0]}/`;
4267
5368
  const appId = (id) => `${prefix}${id}`;
@@ -4274,7 +5375,7 @@ function namespaceAppIr(appIr, appTitle) {
4274
5375
  }
4275
5376
  function composeCarveEstate(tfIr, appNs, opts) {
4276
5377
  const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
4277
- const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
5378
+ const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.map((n) => n.id) });
4278
5379
  const memberH = (plan) => TITLE + PAD + plan.height + PAD;
4279
5380
  const memberW = (plan) => plan.width + PAD * 2;
4280
5381
  const appX0 = memberW(tfPlan) + MEMBER_GAP;
@@ -4313,23 +5414,25 @@ function composeCarveEstate(tfIr, appNs, opts) {
4313
5414
  };
4314
5415
  }
4315
5416
  function renderCarveMorph(tfIr, appIr, carved, opts) {
4316
- const appNs = namespaceAppIr(appIr, opts.appTitle);
4317
- const before = composeCarveEstate(tfIr, appNs, opts);
5417
+ const staged = withCarveState(tfIr, appIr, opts);
5418
+ const appNs = staged.appNs;
5419
+ const morphOpts = { ...opts, carvedTitle: "carved so far" };
5420
+ const before = composeCarveEstate(staged.tf, appNs, morphOpts);
4318
5421
  const gone = new Set(carved);
4319
- const bands = tfIr.groups.byStack ?? {};
5422
+ const bands = staged.tf.groups.byStack ?? {};
4320
5423
  const bandsAfter = {};
4321
5424
  for (const [band, members] of Object.entries(bands)) {
4322
5425
  const left = members.filter((id) => !gone.has(id));
4323
5426
  if (left.length) bandsAfter[band] = left;
4324
5427
  }
4325
5428
  const tfAfter = {
4326
- nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
4327
- edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
5429
+ nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)),
5430
+ edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
4328
5431
  groups: { byStack: bandsAfter }
4329
5432
  };
4330
- const carvedNodes = tfIr.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
5433
+ const carvedNodes = staged.tf.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
4331
5434
  const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
4332
- const after = composeCarveEstate(tfAfter, appAfter, opts);
5435
+ const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
4333
5436
  const view = (name, comp) => ({
4334
5437
  name,
4335
5438
  ir: comp.ir,
@@ -4409,6 +5512,92 @@ function packComponents(layout, ir) {
4409
5512
  layout.width = Math.max(...nodes.map((n) => n.x + halfW(n))) + pad;
4410
5513
  layout.height = Math.max(...nodes.map((n) => n.y + halfH(n))) + pad;
4411
5514
  }
5515
+ function packMemberBoxes(layout, ir, members) {
5516
+ const boxes = layout.groups ?? [];
5517
+ if (boxes.length < 4) return;
5518
+ const nodes = layout.nodes;
5519
+ const size = footprints(ir);
5520
+ const halfW = (n) => (size.get(n.id)?.w ?? NODE_W) / 2;
5521
+ const halfH = (n) => (size.get(n.id)?.h ?? NODE_H) / 2;
5522
+ for (const n of nodes) n.y = layout.height - n.y;
5523
+ for (const b of boxes) b.y = layout.height - b.y;
5524
+ const byId = new Map(nodes.map((n) => [n.id, n]));
5525
+ const claimed = /* @__PURE__ */ new Set();
5526
+ const blocks = boxes.map((box) => {
5527
+ const ns = (members[box.id ?? box.title] ?? members[box.title] ?? []).flatMap((id) => byId.get(id) ?? []);
5528
+ for (const n of ns) claimed.add(n.id);
5529
+ let minX = box.x - box.w / 2;
5530
+ let maxX2 = box.x + box.w / 2;
5531
+ let minY = box.y - box.h / 2;
5532
+ let maxY2 = box.y + box.h / 2;
5533
+ for (const n of ns) {
5534
+ minX = Math.min(minX, n.x - halfW(n));
5535
+ maxX2 = Math.max(maxX2, n.x + halfW(n));
5536
+ minY = Math.min(minY, n.y - halfH(n));
5537
+ maxY2 = Math.max(maxY2, n.y + halfH(n));
5538
+ }
5539
+ return { ns, box, minX, minY, w: maxX2 - minX, h: maxY2 - minY };
5540
+ });
5541
+ const rest = nodes.filter((n) => !claimed.has(n.id));
5542
+ if (rest.length) {
5543
+ const idxOf = new Map(rest.map((n, i) => [n.id, i]));
5544
+ const parent = rest.map((_, i) => i);
5545
+ const find = (x) => {
5546
+ while (parent[x] !== x) x = parent[x] = parent[parent[x]];
5547
+ return x;
5548
+ };
5549
+ for (const e of ir.edges) {
5550
+ const a = idxOf.get(e.from);
5551
+ const b = idxOf.get(e.to);
5552
+ if (a != null && b != null) parent[find(a)] = find(b);
5553
+ }
5554
+ const comps = /* @__PURE__ */ new Map();
5555
+ rest.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))).push(n));
5556
+ for (const ns of comps.values()) {
5557
+ const minX = Math.min(...ns.map((n) => n.x - halfW(n)));
5558
+ const minY = Math.min(...ns.map((n) => n.y - halfH(n)));
5559
+ blocks.push({
5560
+ ns,
5561
+ minX,
5562
+ minY,
5563
+ w: Math.max(...ns.map((n) => n.x + halfW(n))) - minX,
5564
+ h: Math.max(...ns.map((n) => n.y + halfH(n))) - minY
5565
+ });
5566
+ }
5567
+ }
5568
+ const totalArea = blocks.reduce((s, b) => s + (b.w + MEMBER_GAP) * (b.h + MEMBER_GAP), 0);
5569
+ const targetW = Math.max(Math.max(...blocks.map((b) => b.w)), Math.sqrt(totalArea) * 1.3);
5570
+ let shelfX = 0;
5571
+ let shelfY = 0;
5572
+ let shelfH = 0;
5573
+ let maxX = 0;
5574
+ let maxY = 0;
5575
+ for (const b of blocks) {
5576
+ if (shelfX > 0 && shelfX + b.w > targetW) {
5577
+ shelfX = 0;
5578
+ shelfY += shelfH + MEMBER_GAP;
5579
+ shelfH = 0;
5580
+ }
5581
+ const dx = shelfX - b.minX;
5582
+ const dy = shelfY - b.minY;
5583
+ for (const n of b.ns) {
5584
+ n.x += dx;
5585
+ n.y += dy;
5586
+ }
5587
+ if (b.box) {
5588
+ b.box.x += dx;
5589
+ b.box.y += dy;
5590
+ }
5591
+ maxX = Math.max(maxX, shelfX + b.w);
5592
+ maxY = Math.max(maxY, shelfY + b.h);
5593
+ shelfX += b.w + MEMBER_GAP;
5594
+ shelfH = Math.max(shelfH, b.h);
5595
+ }
5596
+ layout.width = maxX;
5597
+ layout.height = maxY;
5598
+ for (const n of nodes) n.y = maxY - n.y;
5599
+ for (const b of boxes) b.y = maxY - b.y;
5600
+ }
4412
5601
  function groupKeyByNode(ir) {
4413
5602
  const out = /* @__PURE__ */ new Map();
4414
5603
  for (const n of ir.nodes) {
@@ -4505,12 +5694,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
4505
5694
  }
4506
5695
 
4507
5696
  // src/carve-actions.ts
4508
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4509
- import { isAbsolute, join as join10, relative, resolve as resolve2, sep } from "node:path";
5697
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
5698
+ import { isAbsolute, join as join13, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
4510
5699
 
4511
5700
  // src/layout.ts
4512
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
4513
- import { join as join8 } from "node:path";
5701
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
5702
+ import { join as join11 } from "node:path";
4514
5703
  var LAYOUT_DIR = ".behold";
4515
5704
  var LAYOUT_FILE = "layout.json";
4516
5705
  var MAX_BODY_BYTES = 64 * 1024;
@@ -4551,7 +5740,7 @@ function lensFromQuery(params) {
4551
5740
  return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
4552
5741
  }
4553
5742
  function layoutPath(projectDir) {
4554
- return join8(projectDir, LAYOUT_DIR, LAYOUT_FILE);
5743
+ return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
4555
5744
  }
4556
5745
  function unwritableReason(projectDir) {
4557
5746
  if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
@@ -4567,7 +5756,7 @@ function readLayoutFile(projectDir) {
4567
5756
  const file = layoutPath(projectDir);
4568
5757
  try {
4569
5758
  if (!existsSync6(file)) return empty;
4570
- const raw = readFileSync7(file, "utf8");
5759
+ const raw = readFileSync8(file, "utf8");
4571
5760
  if (raw.length > MAX_FILE_BYTES) return empty;
4572
5761
  const parsed = JSON.parse(raw);
4573
5762
  if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
@@ -4607,7 +5796,7 @@ function writeLens(projectDir, lens, deltas) {
4607
5796
  }
4608
5797
  const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
4609
5798
  if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
4610
- mkdirSync2(join8(projectDir, LAYOUT_DIR), { recursive: true });
5799
+ mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
4611
5800
  const tmp = `${file}.tmp`;
4612
5801
  writeFileSync2(tmp, body, "utf8");
4613
5802
  renameSync(tmp, file);
@@ -4692,8 +5881,8 @@ function reanchorEdges(svg, moved) {
4692
5881
 
4693
5882
  // src/carve-live.ts
4694
5883
  import { spawn as spawn3 } from "node:child_process";
4695
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
4696
- import { join as join9 } from "node:path";
5884
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
5885
+ import { join as join12 } from "node:path";
4697
5886
  var LIVE_CONTAINER = "behold-carve-floci";
4698
5887
  var LIVE_PORT = 4602;
4699
5888
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -4752,9 +5941,9 @@ async function teardownScratchFloci() {
4752
5941
  await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
4753
5942
  }
4754
5943
  async function applyIntoFloci(fromDir, step) {
4755
- const template = join9(fromDir, "floci-override.tf.disabled");
5944
+ const template = join12(fromDir, "floci-override.tf.disabled");
4756
5945
  if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
4757
- writeFileSync3(join9(fromDir, "floci_override.tf"), armOverride(readFileSync8(template, "utf8"), LIVE_PORT));
5946
+ writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
4758
5947
  const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
4759
5948
  if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
4760
5949
  const apply = await step(
@@ -4778,7 +5967,7 @@ ${tail}`, exitCode: r.code };
4778
5967
  // src/carve-actions.ts
4779
5968
  async function runCarvePlan(demo) {
4780
5969
  if (!demo?.live) {
4781
- return refuse2(
5970
+ return refuse3(
4782
5971
  "carve-action",
4783
5972
  "terraform plan is the live tier's beat, and this server isn't serving one",
4784
5973
  "run `behold demo carve --live` (needs docker + terraform)"
@@ -4786,7 +5975,7 @@ async function runCarvePlan(demo) {
4786
5975
  }
4787
5976
  const r = await runLivePlan(demo.from);
4788
5977
  if ("error" in r) {
4789
- return refuse2("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
5978
+ return refuse3("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
4790
5979
  }
4791
5980
  return {
4792
5981
  ok: true,
@@ -4796,16 +5985,85 @@ async function runCarvePlan(demo) {
4796
5985
  noDestroy: r.noDestroy
4797
5986
  };
4798
5987
  }
5988
+ async function runCarveObserve(demo, select) {
5989
+ if (!demo.live) {
5990
+ return refuse3(
5991
+ "carve-action",
5992
+ "the observe beat reads the scratch Floci, and this server isn't serving one",
5993
+ "run `behold demo carve --live` (needs docker + terraform)"
5994
+ );
5995
+ }
5996
+ if (!existsSync8(join13(demo.out, "src"))) {
5997
+ return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
5998
+ }
5999
+ const args = ["lifecycle", "diff", "prod", "--live", "--json"];
6000
+ const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
6001
+ code: 127,
6002
+ stdout: "",
6003
+ stderr: err instanceof Error ? err.message : String(err)
6004
+ }));
6005
+ if (run3.code !== 0) {
6006
+ return refuse3(
6007
+ "carve-action",
6008
+ `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
6009
+ "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
6010
+ );
6011
+ }
6012
+ let parsed;
6013
+ try {
6014
+ parsed = JSON.parse(run3.stdout);
6015
+ } catch {
6016
+ return refuse3("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
6017
+ }
6018
+ if (parsed.stacks && !parsed.lexicons) {
6019
+ return refuse3(
6020
+ "carve-action",
6021
+ "chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
6022
+ "point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
6023
+ );
6024
+ }
6025
+ const aws = parsed.lexicons?.aws ?? {};
6026
+ const entity = select.split(".").pop() ?? select;
6027
+ const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
6028
+ const queried = aws.resources?.queried?.[entity];
6029
+ const meta = aws.observed?.[entity];
6030
+ if (meta) {
6031
+ return {
6032
+ ok: true,
6033
+ select,
6034
+ command,
6035
+ entity,
6036
+ verdict: "observed",
6037
+ ...meta.status ? { status: meta.status } : {},
6038
+ ...meta.ownership ? { ownership: meta.ownership } : {},
6039
+ ...meta.physicalId ? { physicalId: meta.physicalId } : {},
6040
+ ...queried ? { queried } : {}
6041
+ };
6042
+ }
6043
+ const hole = aws.resources?.unobserved?.find((u) => u.name === entity);
6044
+ if (hole) {
6045
+ return {
6046
+ ok: true,
6047
+ select,
6048
+ command,
6049
+ entity,
6050
+ verdict: "unobserved",
6051
+ ...hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {},
6052
+ ...queried ? { queried } : {}
6053
+ };
6054
+ }
6055
+ return { ok: true, select, command, entity, verdict: "missing", ...queried ? { queried } : {} };
6056
+ }
4799
6057
  var MAX_ARTIFACT_BYTES = 64 * 1024;
4800
6058
  var MAX_ARTIFACTS = 24;
4801
6059
  var MAX_OUTPUT_BYTES = 32 * 1024;
4802
- var refuse2 = (code, error, remedy) => ({
6060
+ var refuse3 = (code, error, remedy) => ({
4803
6061
  ok: false,
4804
6062
  refusal: { error, code, remedy }
4805
6063
  });
4806
6064
  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.";
4807
6065
  function insideDemo(root, p) {
4808
- const rel = relative(resolve2(root), resolve2(p));
6066
+ const rel = relative2(resolve4(root), resolve4(p));
4809
6067
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4810
6068
  }
4811
6069
  function selectFromReport(report, raw) {
@@ -4826,17 +6084,17 @@ function readArtifacts(demo, dir, filter) {
4826
6084
  if (out.length >= MAX_ARTIFACTS || depth > 2) return;
4827
6085
  let entries;
4828
6086
  try {
4829
- entries = readdirSync3(d).sort();
6087
+ entries = readdirSync5(d).sort();
4830
6088
  } catch {
4831
6089
  return;
4832
6090
  }
4833
6091
  for (const name of entries) {
4834
6092
  if (out.length >= MAX_ARTIFACTS) return;
4835
6093
  if (name === "node_modules" || name.startsWith(".")) continue;
4836
- const full = join10(d, name);
6094
+ const full = join13(d, name);
4837
6095
  let st;
4838
6096
  try {
4839
- st = statSync2(full);
6097
+ st = statSync5(full);
4840
6098
  } catch {
4841
6099
  continue;
4842
6100
  }
@@ -4844,11 +6102,11 @@ function readArtifacts(demo, dir, filter) {
4844
6102
  walk(full, depth + 1);
4845
6103
  continue;
4846
6104
  }
4847
- const rel = relative(demo.root, full).split(sep).join("/");
6105
+ const rel = relative2(demo.root, full).split(sep2).join("/");
4848
6106
  if (!filter(rel)) continue;
4849
6107
  let text = "";
4850
6108
  try {
4851
- text = readFileSync9(full, "utf8");
6109
+ text = readFileSync10(full, "utf8");
4852
6110
  } catch {
4853
6111
  continue;
4854
6112
  }
@@ -4870,17 +6128,17 @@ function carveSlug(select) {
4870
6128
  }
4871
6129
  var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
4872
6130
  function shortenIn(arg, root) {
4873
- return arg.startsWith(root + sep) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
6131
+ return arg.startsWith(root + sep2) ? arg.slice(root.length + 1) : arg === root ? "." : arg;
4874
6132
  }
4875
6133
  function merge(r, root) {
4876
6134
  const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
4877
- return clip(joined.split(root + sep).join("").split(root).join("."));
6135
+ return clip(joined.split(root + sep2).join("").split(root).join("."));
4878
6136
  }
4879
6137
  async function runCarveEmit(demo, select) {
4880
6138
  const block = carveWriteBlock(demo);
4881
- if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
6139
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
4882
6140
  mkdirSync3(demo.out, { recursive: true });
4883
- const reportFile = join10(demo.out, `${carveSlug(select)}-boundary.json`);
6141
+ const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
4884
6142
  const args = [
4885
6143
  "carve",
4886
6144
  "emit",
@@ -4901,7 +6159,7 @@ async function runCarveEmit(demo, select) {
4901
6159
  }));
4902
6160
  const output = merge(run3, demo.root);
4903
6161
  if (run3.code !== 0) {
4904
- return refuse2(
6162
+ return refuse3(
4905
6163
  "carve-action",
4906
6164
  `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
4907
6165
  "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`."
@@ -4909,10 +6167,10 @@ async function runCarveEmit(demo, select) {
4909
6167
  }
4910
6168
  let boundary = null;
4911
6169
  try {
4912
- boundary = JSON.parse(readFileSync9(reportFile, "utf8"));
6170
+ boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
4913
6171
  } catch {
4914
6172
  }
4915
- const lintPath = relative(demo.project, join10(demo.out, "src")).split(sep).join("/");
6173
+ const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
4916
6174
  const lintArgs = ["lint", lintPath];
4917
6175
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
4918
6176
  code: 127,
@@ -4936,7 +6194,7 @@ async function runCarveEmit(demo, select) {
4936
6194
  }
4937
6195
  async function runCarveBridge(demo, select) {
4938
6196
  const block = carveWriteBlock(demo);
4939
- if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
6197
+ if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
4940
6198
  mkdirSync3(demo.out, { recursive: true });
4941
6199
  const args = [
4942
6200
  "carve",
@@ -4956,7 +6214,7 @@ async function runCarveBridge(demo, select) {
4956
6214
  }));
4957
6215
  const output = merge(run3, demo.root);
4958
6216
  if (run3.code !== 0) {
4959
- return refuse2(
6217
+ return refuse3(
4960
6218
  "carve-action",
4961
6219
  `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
4962
6220
  "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
@@ -4975,8 +6233,8 @@ async function runCarveBridge(demo, select) {
4975
6233
  }
4976
6234
 
4977
6235
  // src/ops.ts
4978
- import { readdirSync as readdirSync4, readFileSync as readFileSync10, existsSync as existsSync9 } from "node:fs";
4979
- import { join as join11 } from "node:path";
6236
+ import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
6237
+ import { join as join14 } from "node:path";
4980
6238
  var APPLY_TARGET_LEXICON = {
4981
6239
  cloudformation: "aws",
4982
6240
  kubectl: "k8s",
@@ -4994,45 +6252,623 @@ function kindOf(content) {
4994
6252
  if (/AuditOp\b/.test(content)) return "audit";
4995
6253
  return "op";
4996
6254
  }
4997
- function discoverOps(projectDir) {
4998
- const seen = /* @__PURE__ */ new Set();
6255
+ function discoverOps(projectDir) {
6256
+ const seen = /* @__PURE__ */ new Set();
6257
+ const out = [];
6258
+ for (const sub of ["ops", "src", "."]) {
6259
+ const dir = join14(projectDir, sub);
6260
+ if (!existsSync9(dir)) continue;
6261
+ for (const f of readdirSync6(dir)) {
6262
+ if (!f.endsWith(".op.ts")) continue;
6263
+ const content = readFileSync11(join14(dir, f), "utf8");
6264
+ const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
6265
+ if (!name || seen.has(name)) continue;
6266
+ seen.add(name);
6267
+ const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
6268
+ const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
6269
+ const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
6270
+ const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
6271
+ const irPath = opIrPath(projectDir, name);
6272
+ out.push({
6273
+ name,
6274
+ kind: kindOf(content),
6275
+ dir: projectDir,
6276
+ ...gate ? { gate } : {},
6277
+ ...env ? { env } : {},
6278
+ ...substrate ? { substrate } : {},
6279
+ ...existsSync9(irPath) ? { irPath } : {}
6280
+ });
6281
+ }
6282
+ }
6283
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6284
+ }
6285
+ function opIrPath(projectDir, name) {
6286
+ return join14(projectDir, "dist", "ops", name, "op.json");
6287
+ }
6288
+ function discoverOpIrs(projectDirs) {
6289
+ const seen = /* @__PURE__ */ new Set();
6290
+ const out = [];
6291
+ for (const dir of projectDirs) {
6292
+ const root = join14(dir, "dist", "ops");
6293
+ if (!existsSync9(root)) continue;
6294
+ for (const name of readdirSync6(root)) {
6295
+ if (seen.has(name)) continue;
6296
+ const path = opIrPath(dir, name);
6297
+ if (!existsSync9(path)) continue;
6298
+ seen.add(name);
6299
+ out.push({ name, dir, path });
6300
+ }
6301
+ }
6302
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6303
+ }
6304
+ function discoverEstateOps(projectDirs) {
6305
+ const seen = /* @__PURE__ */ new Set();
6306
+ const out = [];
6307
+ for (const dir of projectDirs) {
6308
+ for (const op of discoverOps(dir)) {
6309
+ if (seen.has(op.name)) continue;
6310
+ seen.add(op.name);
6311
+ out.push(op);
6312
+ }
6313
+ }
6314
+ return out.sort((a, b) => a.name.localeCompare(b.name));
6315
+ }
6316
+
6317
+ // src/run-playhead.ts
6318
+ var RECEIPT_READ_FN = "receiptRead";
6319
+ var RECEIPT_WRITE_FN = "receiptWrite";
6320
+ var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
6321
+ var isRecord4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6322
+ function asStepRecord(value) {
6323
+ if (!isRecord4(value)) return null;
6324
+ if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
6325
+ if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
6326
+ if (typeof value.durationMs !== "number") return null;
6327
+ return value;
6328
+ }
6329
+ function parseRunProgressLine(line) {
6330
+ let parsed;
6331
+ try {
6332
+ parsed = JSON.parse(line);
6333
+ } catch {
6334
+ return null;
6335
+ }
6336
+ const one = asStepRecord(parsed);
6337
+ if (one) return [one];
6338
+ if (isRecord4(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
6339
+ const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
6340
+ return records.length ? records : null;
6341
+ }
6342
+ return null;
6343
+ }
6344
+ function recordLine(record) {
6345
+ const mark = record.status === "ok" ? "\u2713" : record.status === "fail" ? "\u2717" : "\u2022";
6346
+ const took = record.status === "skipped" ? "skipped" : record.durationMs < 1e3 ? `${record.durationMs}ms` : `${(record.durationMs / 1e3).toFixed(1)}s`;
6347
+ return `${mark} ${record.phase} \xB7 ${record.fn} \u2014 ${took}${record.error ? ` \u2014 ${record.error}` : ""}`;
6348
+ }
6349
+ var ANSI = /\x1b\[[0-9;]*m/g;
6350
+ var strip = (s) => s.replace(ANSI, "");
6351
+ function statusFields(stdout) {
6352
+ const out = /* @__PURE__ */ new Map();
6353
+ for (const raw of strip(stdout).split("\n")) {
6354
+ const m = /^\s{2,}([A-Za-z][A-Za-z ]*?)\s*:\s*(.*)$/.exec(raw);
6355
+ if (m) out.set(m[1].trim(), m[2].trim());
6356
+ }
6357
+ return out;
6358
+ }
6359
+ function parseGateLine(line) {
6360
+ const m = /^(.*?)(?: — (.*?))? \(waiting since (.+)\)$/.exec(line.trim());
6361
+ if (!m) return null;
6362
+ const signalName = m[1].trim();
6363
+ if (!signalName) return null;
6364
+ return { signalName, ...m[2] ? { description: m[2].trim() } : {}, since: m[3].trim() };
6365
+ }
6366
+ function readRunStatus(op, run3) {
6367
+ if (run3.code !== 0) {
6368
+ const message = strip(run3.stderr).trim() || strip(run3.stdout).trim() || `chant run status exited ${run3.code}`;
6369
+ if (/not available in local mode|@temporalio\/client is not installed/.test(message)) {
6370
+ return {
6371
+ ok: false,
6372
+ refusal: {
6373
+ error: `No Temporal run state for "${op}" \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6374
+ code: "no-temporal",
6375
+ 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."
6376
+ }
6377
+ };
6378
+ }
6379
+ if (/not found|NotFound/.test(message)) {
6380
+ return {
6381
+ ok: false,
6382
+ refusal: {
6383
+ error: `No run of "${op}" to report \u2014 ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6384
+ code: "no-run",
6385
+ remedy: "Start the Op (Run) and the playhead fills in as each step settles."
6386
+ }
6387
+ };
6388
+ }
6389
+ return {
6390
+ ok: false,
6391
+ refusal: {
6392
+ error: `chant run status ${op} failed: ${message.split("\n")[0].replace(/^error:\s*/, "")}`,
6393
+ code: "run-status",
6394
+ remedy: "Run `chant run status " + op + " --temporal` in the project to see the whole error."
6395
+ }
6396
+ };
6397
+ }
6398
+ const f = statusFields(run3.stdout);
6399
+ const activities = /^(\d+)\/(\d+)/.exec(f.get("Activities") ?? "");
6400
+ const gateLine = f.get("Gate");
6401
+ return {
6402
+ ok: true,
6403
+ read: {
6404
+ op,
6405
+ ...f.get("Workflow ID") ? { workflowId: f.get("Workflow ID") } : {},
6406
+ ...f.get("Run ID") ? { runId: f.get("Run ID") } : {},
6407
+ ...f.get("Status") ? { status: f.get("Status") } : {},
6408
+ ...f.get("Started") ? { startedAt: f.get("Started") } : {},
6409
+ ...f.get("Closed") ? { closedAt: f.get("Closed") } : {},
6410
+ ...activities ? { activities: { completed: Number(activities[1]), scheduled: Number(activities[2]) } } : {},
6411
+ // No Gate line means chant printed none, which it does both when the
6412
+ // query says "nothing pending" and when the Op registers no handler.
6413
+ // Either way: no gate is waiting on a human right now.
6414
+ gate: gateLine ? parseGateLine(gateLine) : null
6415
+ }
6416
+ };
6417
+ }
6418
+ var initialRunState = {
6419
+ op: null,
6420
+ mode: null,
6421
+ status: "idle",
6422
+ records: [],
6423
+ gate: null,
6424
+ gateNote: null,
6425
+ startedAt: null,
6426
+ endedAt: null,
6427
+ lost: null
6428
+ };
6429
+ function runStarted(op, mode, at) {
6430
+ return { ...initialRunState, op, mode, status: "running", startedAt: at };
6431
+ }
6432
+ function runRecord(state, record) {
6433
+ return { ...state, records: [...state.records, record] };
6434
+ }
6435
+ function runGate(state, result) {
6436
+ return result.ok ? { ...state, gate: result.read.gate, gateNote: null } : { ...state, gate: null, gateNote: result.refusal.error };
6437
+ }
6438
+ function runEnded(state, exitCode, at) {
6439
+ if (state.status !== "running") return state;
6440
+ if (exitCode === 0) return { ...state, status: "ok", endedAt: at };
6441
+ if (state.records.some((r) => r.status === "fail")) return { ...state, status: "failed", endedAt: at };
6442
+ return {
6443
+ ...state,
6444
+ status: "lost",
6445
+ endedAt: at,
6446
+ 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.`
6447
+ };
6448
+ }
6449
+ function slotsFor(placed) {
6450
+ const out = [];
6451
+ for (const p of placed) {
6452
+ if (p.step.kind === "activity") {
6453
+ out.push({ id: p.id, phase: p.phase, fn: p.step.fn });
6454
+ } else if (p.step.kind === "effect") {
6455
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_READ_FN });
6456
+ for (const nested of p.step.steps ?? []) {
6457
+ if (nested.kind === "activity") out.push({ id: p.id, phase: p.phase, fn: nested.fn });
6458
+ }
6459
+ out.push({ id: p.id, phase: p.phase, fn: RECEIPT_WRITE_FN });
6460
+ }
6461
+ }
6462
+ return out;
6463
+ }
6464
+ function mergeRun(prev, record) {
6465
+ const rank = { fail: 3, ok: 2, skipped: 1 };
6466
+ const keepPrev = prev && rank[prev.status] >= rank[record.status];
6467
+ return {
6468
+ status: keepPrev ? prev.status : record.status,
6469
+ durationMs: (prev?.durationMs ?? 0) + record.durationMs,
6470
+ ...keepPrev && prev.error ? { error: prev.error } : record.error ? { error: record.error } : {},
6471
+ ...keepPrev && prev.outcome ? { outcome: prev.outcome } : record.outcome ? { outcome: `${record.outcome.name}=${String(record.outcome.value)}` } : {},
6472
+ records: (prev?.records ?? 0) + 1
6473
+ };
6474
+ }
6475
+ function joinRun(op, records, gate) {
6476
+ const placed = opPlacedSteps(op);
6477
+ const slots = slotsFor(placed);
6478
+ const byKey = /* @__PURE__ */ new Map();
6479
+ for (const s of slots) {
6480
+ const key = `${s.phase}\0${s.fn}`;
6481
+ if (!byKey.has(key)) byKey.set(key, []);
6482
+ byKey.get(key).push({ id: s.id });
6483
+ }
6484
+ const cursor = /* @__PURE__ */ new Map();
6485
+ const byNode = /* @__PURE__ */ new Map();
6486
+ const unplaced = [];
6487
+ let lastSettledIndex = -1;
6488
+ const indexOf = new Map(placed.map((p, i) => [p.id, i]));
6489
+ for (const record of records) {
6490
+ const key = `${record.phase}\0${record.fn}`;
6491
+ const list3 = byKey.get(key);
6492
+ const i = cursor.get(key) ?? 0;
6493
+ const slot = list3?.[i];
6494
+ if (!slot) {
6495
+ unplaced.push(record);
6496
+ continue;
6497
+ }
6498
+ cursor.set(key, i + 1);
6499
+ byNode.set(slot.id, mergeRun(byNode.get(slot.id), record));
6500
+ if (record.status !== "skipped") {
6501
+ lastSettledIndex = Math.max(lastSettledIndex, indexOf.get(slot.id) ?? -1);
6502
+ }
6503
+ }
6504
+ if (gate) {
6505
+ const gateNode = placed.find((p) => p.step.kind === "gate" && p.step.signalName === gate.signalName);
6506
+ if (gateNode) {
6507
+ return {
6508
+ byNode,
6509
+ at: gateNode.id,
6510
+ atReason: `waiting for ${gate.signalName} since ${gate.since}`,
6511
+ unplaced
6512
+ };
6513
+ }
6514
+ }
6515
+ const next = placed.find((p, i) => i > lastSettledIndex && p.when === "phases");
6516
+ return {
6517
+ byNode,
6518
+ at: lastSettledIndex >= 0 && next ? next.id : null,
6519
+ atReason: lastSettledIndex >= 0 && next ? "reached \u2014 not settled yet" : null,
6520
+ unplaced
6521
+ };
6522
+ }
6523
+ var TONE = {
6524
+ ok: "good",
6525
+ fail: "warn",
6526
+ skipped: "neutral"
6527
+ };
6528
+ var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
6529
+ function tookLabel(ms) {
6530
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
6531
+ }
6532
+ function paintPlayhead(ir, join21, gate) {
6533
+ if (!join21.byNode.size && !join21.at) return ir;
6534
+ const nodes = ir.nodes.map((node) => {
6535
+ const run3 = join21.byNode.get(node.id);
6536
+ const here = join21.at === node.id;
6537
+ if (!run3 && !here) return node;
6538
+ const isGate = node.attrs?._step === "gate";
6539
+ const attrs = { ...node.attrs };
6540
+ if (run3) {
6541
+ attrs._status = TONE[run3.status];
6542
+ attrs.run = run3.status === "skipped" ? "skipped" : `${RAN[run3.status]} \xB7 ${tookLabel(run3.durationMs)}`;
6543
+ attrs.ran = tookLabel(run3.durationMs);
6544
+ if (run3.error) attrs.error = run3.error;
6545
+ if (run3.outcome) attrs.outcome = run3.outcome;
6546
+ if (run3.records > 1) attrs.runRecords = run3.records;
6547
+ }
6548
+ if (here) {
6549
+ attrs._playhead = true;
6550
+ attrs._status = isGate && gate ? "warn" : "accent";
6551
+ attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
6552
+ if (isGate && gate?.description) attrs.gateDescription = gate.description;
6553
+ }
6554
+ return { ...node, attrs };
6555
+ });
6556
+ return { ...ir, nodes };
6557
+ }
6558
+ function pendingGateCard(op, gate, ir) {
6559
+ const node = ir.nodes.find((n) => n.attrs?._step === "gate" && n.attrs?.gate === gate.signalName && n.attrs?.op === op);
6560
+ return {
6561
+ op,
6562
+ signalName: gate.signalName,
6563
+ ...gate.description ? { description: gate.description } : {},
6564
+ since: gate.since,
6565
+ ...node ? { node: node.id } : {},
6566
+ ...node && typeof node.attrs?.guards === "string" ? { guards: node.attrs.guards } : {},
6567
+ approve: {
6568
+ method: "POST",
6569
+ path: `/api/ops/${encodeURIComponent(op)}/signal/${encodeURIComponent(gate.signalName)}`
6570
+ }
6571
+ };
6572
+ }
6573
+ function playheadNote(state, join21) {
6574
+ if (!state.op) {
6575
+ return "No run state: nothing has been run from this behold session, so every step paints as declared.";
6576
+ }
6577
+ const settled = join21 ? join21.byNode.size : state.records.length;
6578
+ const bits = [];
6579
+ 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`;
6580
+ if (state.status === "running") {
6581
+ bits.push(
6582
+ 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"
6583
+ );
6584
+ }
6585
+ if (state.status === "ok") bits.push("the run exited cleanly");
6586
+ if (state.status === "failed") bits.push("a step failed");
6587
+ if (state.status === "lost") bits.push(`stream lost \u2014 ${state.lost ?? "the progress stream ended without a verdict"}`);
6588
+ if (state.gate) {
6589
+ bits.push(`waiting on gate ${state.gate.signalName} since ${state.gate.since} \u2014 approve is a delegated signal, behold never signals on its own`);
6590
+ } else if (state.gateNote) {
6591
+ bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
6592
+ }
6593
+ if (join21?.unplaced.length) {
6594
+ bits.push(
6595
+ `${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
6596
+ );
6597
+ }
6598
+ if (!bits.length) return `${head}.`;
6599
+ const rest = bits.join("; ");
6600
+ return `${head} \u2014 ${rest}.`;
6601
+ }
6602
+
6603
+ // src/operator.ts
6604
+ var ANSI2 = /\x1b\[[0-9;]*m/g;
6605
+ var strip2 = (s) => s.replace(ANSI2, "");
6606
+ var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6607
+ 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).";
6608
+ function readRow(value) {
6609
+ if (!isRecord5(value)) return null;
6610
+ if (typeof value.op !== "string" || typeof value.env !== "string") return null;
6611
+ if (!Array.isArray(value.pendingGates)) return null;
6612
+ const gates = [];
6613
+ for (const g of value.pendingGates) {
6614
+ if (!isRecord5(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
6615
+ gates.push({ rule: g.rule, gate: g.gate, ...typeof g.op === "string" ? { op: g.op } : {} });
6616
+ }
6617
+ const tick = isRecord5(value.lastTick) ? value.lastTick : void 0;
6618
+ const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
6619
+ const lease = isRecord5(value.lease) ? value.lease : void 0;
6620
+ return {
6621
+ op: value.op,
6622
+ env: value.env,
6623
+ ...usable ? { lastTick: tick } : {},
6624
+ pendingGates: gates,
6625
+ ...lease && typeof lease.holder === "string" && typeof lease.expiresAt === "string" ? { lease: { holder: lease.holder, expiresAt: lease.expiresAt } } : {}
6626
+ };
6627
+ }
6628
+ function readOperatorStatus(run3) {
6629
+ const err = strip2(run3.stderr);
6630
+ const out = strip2(run3.stdout).trim();
6631
+ if (run3.code !== 0) {
6632
+ const message = err.trim() || out || `chant operator status exited ${run3.code}`;
6633
+ const first = message.split("\n")[0].replace(/^error:\s*/i, "");
6634
+ if (/Unknown command|Unknown operator subcommand/i.test(message)) {
6635
+ return {
6636
+ ok: false,
6637
+ refusal: {
6638
+ error: `This chant has no \`operator status\` \u2014 ${first}`,
6639
+ code: "no-operator-cli",
6640
+ remedy: "The operating loop's status surface landed in chant 0.52 (chant#1485). Upgrade chant to read it."
6641
+ }
6642
+ };
6643
+ }
6644
+ return {
6645
+ ok: false,
6646
+ refusal: {
6647
+ error: `chant operator status failed: ${first}`,
6648
+ code: "operator-status",
6649
+ remedy: "Run `chant operator status --json` in the project to see the whole error."
6650
+ }
6651
+ };
6652
+ }
6653
+ if (!out) {
6654
+ return {
6655
+ ok: false,
6656
+ refusal: {
6657
+ error: "This project declares no operating loop \u2014 chant found no ConvergeOp.",
6658
+ code: "no-operator",
6659
+ remedy: HOW_TO_DECLARE
6660
+ }
6661
+ };
6662
+ }
6663
+ let parsed;
6664
+ try {
6665
+ parsed = JSON.parse(out);
6666
+ } catch (e) {
6667
+ return {
6668
+ ok: false,
6669
+ refusal: {
6670
+ error: `chant operator status --json didn't print JSON: ${e instanceof Error ? e.message : String(e)}`,
6671
+ code: "operator-status",
6672
+ remedy: "Run `chant operator status --json` in the project and check what it printed."
6673
+ }
6674
+ };
6675
+ }
6676
+ if (!Array.isArray(parsed)) {
6677
+ return {
6678
+ ok: false,
6679
+ refusal: {
6680
+ error: "chant operator status --json answered with something that isn't an array of ConvergeOp rows.",
6681
+ code: "operator-status",
6682
+ remedy: "behold reads one row per ConvergeOp: `{op, env, lastTick?, pendingGates[], lease?}`."
6683
+ }
6684
+ };
6685
+ }
6686
+ const rows = [];
6687
+ for (const value of parsed) {
6688
+ const row = readRow(value);
6689
+ if (!row) {
6690
+ return {
6691
+ ok: false,
6692
+ refusal: {
6693
+ error: "chant operator status --json answered with a row behold couldn't read (needs `op`, `env`, `pendingGates[]`).",
6694
+ code: "operator-status",
6695
+ remedy: "Check `chant operator status --json` in the project \u2014 and file the shape change if chant's is the newer one."
6696
+ }
6697
+ };
6698
+ }
6699
+ rows.push(row);
6700
+ }
6701
+ return { ok: true, rows };
6702
+ }
6703
+ function declaredConvergeOps(ops) {
6704
+ return ops.filter((o) => o.searchAttributes?.Converge === "true").map((o) => ({
6705
+ name: o.name,
6706
+ ...o.searchAttributes?.Env ? { env: o.searchAttributes.Env } : {},
6707
+ ...o.searchAttributes?.Dial ? { dial: o.searchAttributes.Dial } : {}
6708
+ }));
6709
+ }
6710
+ var MANAGED_BY = "app.kubernetes.io/managed-by";
6711
+ var COMPONENT = "app.kubernetes.io/component";
6712
+ var NAME_LABEL = "app.kubernetes.io/name";
6713
+ var CHANT = "chant";
6714
+ var CONVERGE_TICK = "converge-tick";
6715
+ var CRONJOB_KIND = "K8s::Batch::CronJob";
6716
+ var NAMESPACE_KIND = "K8s::Core::Namespace";
6717
+ var rec3 = (v) => isRecord5(v) ? v : void 0;
6718
+ var str3 = (v) => typeof v === "string" && v ? v : void 0;
6719
+ var labelsOf = (n) => rec3(rec3(n.attrs?.metadata)?.labels) ?? {};
6720
+ function operatorHomes(ir) {
6721
+ const byNamespace = /* @__PURE__ */ new Map();
6722
+ const conflicted = /* @__PURE__ */ new Set();
6723
+ for (const n of ir.nodes) {
6724
+ if (n.kind !== CRONJOB_KIND) continue;
6725
+ const labels = labelsOf(n);
6726
+ if (labels[MANAGED_BY] !== CHANT || labels[COMPONENT] !== CONVERGE_TICK) continue;
6727
+ const meta = rec3(n.attrs?.metadata);
6728
+ const ns = str3(meta?.namespace);
6729
+ const op = str3(meta?.name);
6730
+ if (!ns || !op) continue;
6731
+ const home = byNamespace.get(ns) ?? { namespace: ns, ticks: [] };
6732
+ const stack = str3(labels[NAME_LABEL]);
6733
+ if (stack && !conflicted.has(ns)) {
6734
+ if (home.stack === void 0) home.stack = stack;
6735
+ else if (home.stack !== stack) {
6736
+ conflicted.add(ns);
6737
+ delete home.stack;
6738
+ }
6739
+ }
6740
+ const schedule = str3(rec3(n.attrs?.spec)?.schedule);
6741
+ home.ticks.push({ node: n.id, op, ...schedule ? { schedule } : {} });
6742
+ byNamespace.set(ns, home);
6743
+ }
6744
+ if (!byNamespace.size) return [];
6745
+ for (const n of ir.nodes) {
6746
+ if (n.kind !== NAMESPACE_KIND) continue;
6747
+ const name = str3(rec3(n.attrs?.metadata)?.name);
6748
+ const home = name ? byNamespace.get(name) : void 0;
6749
+ if (home && !home.node) home.node = n.id;
6750
+ }
6751
+ for (const home of byNamespace.values()) home.ticks.sort((a, b) => a.op.localeCompare(b.op));
6752
+ return [...byNamespace.values()].sort((a, b) => a.namespace.localeCompare(b.namespace));
6753
+ }
6754
+ function markOperatorHome(ir) {
6755
+ const homes = operatorHomes(ir);
6756
+ if (!homes.length) return ir;
6757
+ const marks = /* @__PURE__ */ new Map();
6758
+ for (const home of homes) {
6759
+ if (home.node) {
6760
+ marks.set(home.node, {
6761
+ role: "home",
6762
+ namespace: home.namespace,
6763
+ ...home.stack ? { stack: home.stack } : {},
6764
+ ticks: home.ticks.map((t) => t.op)
6765
+ });
6766
+ }
6767
+ for (const tick of home.ticks) {
6768
+ marks.set(tick.node, {
6769
+ role: "tick",
6770
+ namespace: home.namespace,
6771
+ ...home.stack ? { stack: home.stack } : {},
6772
+ op: tick.op,
6773
+ ...tick.schedule ? { schedule: tick.schedule } : {}
6774
+ });
6775
+ }
6776
+ }
6777
+ return {
6778
+ ...ir,
6779
+ nodes: ir.nodes.map((n) => {
6780
+ const mark = marks.get(n.id);
6781
+ return mark ? { ...n, attrs: { ...n.attrs, _operator: mark } } : n;
6782
+ })
6783
+ };
6784
+ }
6785
+ function operatorStrip(rows, now = Date.now()) {
6786
+ const out = rows.map((r) => {
6787
+ const expiresAt = r.lease ? Date.parse(r.lease.expiresAt) : NaN;
6788
+ const lease = !r.lease ? "free" : Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : "held";
6789
+ return {
6790
+ op: r.op,
6791
+ env: r.env,
6792
+ log: r.lastTick?.log ?? null,
6793
+ at: r.lastTick?.timestamp ?? null,
6794
+ lease,
6795
+ leaseHolder: r.lease?.holder ?? null,
6796
+ leaseExpiresAt: r.lease?.expiresAt ?? null,
6797
+ pendingGates: r.pendingGates.length
6798
+ };
6799
+ });
6800
+ return { rows: out, pendingGates: out.reduce((n, r) => n + r.pendingGates, 0) };
6801
+ }
6802
+ var CONVERGE_GATE_LOOP = "converge";
6803
+ var APPROVE_SEMANTICS = "Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
6804
+ function convergeGateCards(rows) {
4999
6805
  const out = [];
5000
- for (const sub of ["ops", "src", "."]) {
5001
- const dir = join11(projectDir, sub);
5002
- if (!existsSync9(dir)) continue;
5003
- for (const f of readdirSync4(dir)) {
5004
- if (!f.endsWith(".op.ts")) continue;
5005
- const content = readFileSync10(join11(dir, f), "utf8");
5006
- const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
5007
- if (!name || seen.has(name)) continue;
5008
- seen.add(name);
5009
- const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
5010
- const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
5011
- const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
5012
- const substrate = target ? APPLY_TARGET_LEXICON[target] : void 0;
6806
+ for (const row of rows) {
6807
+ for (const g of row.pendingGates) {
6808
+ if (!g.op) continue;
5013
6809
  out.push({
5014
- name,
5015
- kind: kindOf(content),
5016
- dir: projectDir,
5017
- ...gate ? { gate } : {},
5018
- ...env ? { env } : {},
5019
- ...substrate ? { substrate } : {}
6810
+ loop: CONVERGE_GATE_LOOP,
6811
+ rule: g.rule,
6812
+ op: g.op,
6813
+ gate: g.gate,
6814
+ convergeOp: row.op,
6815
+ env: row.env,
6816
+ since: row.lastTick?.timestamp ?? null,
6817
+ semantics: APPROVE_SEMANTICS,
6818
+ approve: {
6819
+ method: "POST",
6820
+ path: `/api/operator/approve/${encodeURIComponent(g.op)}/${encodeURIComponent(g.gate)}`,
6821
+ command: `chant approve ${g.op} ${g.gate}`
6822
+ }
5020
6823
  });
5021
6824
  }
5022
6825
  }
5023
- return out.sort((a, b) => a.name.localeCompare(b.name));
6826
+ return out;
5024
6827
  }
5025
- function discoverEstateOps(projectDirs) {
5026
- const seen = /* @__PURE__ */ new Set();
5027
- const out = [];
5028
- for (const dir of projectDirs) {
5029
- for (const op of discoverOps(dir)) {
5030
- if (seen.has(op.name)) continue;
5031
- seen.add(op.name);
5032
- out.push(op);
5033
- }
6828
+ var initialOperatorState = {
6829
+ declared: [],
6830
+ strip: null,
6831
+ gates: [],
6832
+ note: null,
6833
+ code: null,
6834
+ readAt: null
6835
+ };
6836
+ function operatorRead(state, result, at, now = Date.now()) {
6837
+ if (!result.ok) {
6838
+ return { ...state, strip: null, gates: [], note: result.refusal.error, code: result.refusal.code, readAt: at };
5034
6839
  }
5035
- return out.sort((a, b) => a.name.localeCompare(b.name));
6840
+ return {
6841
+ ...state,
6842
+ strip: operatorStrip(result.rows, now),
6843
+ gates: convergeGateCards(result.rows),
6844
+ note: null,
6845
+ code: null,
6846
+ readAt: at
6847
+ };
6848
+ }
6849
+ function operatorNote(state) {
6850
+ if (!state.declared.length) {
6851
+ return "No operating loop: this project declares no ConvergeOp, so there is no operator strip.";
6852
+ }
6853
+ const loops = `${state.declared.length} ConvergeOp${state.declared.length === 1 ? "" : "s"}`;
6854
+ if (!state.strip) {
6855
+ return state.note ? `Operating loop: ${loops} declared \u2014 status unavailable: ${state.note}` : `Operating loop: ${loops} declared \u2014 not read yet.`;
6856
+ }
6857
+ const bits = [];
6858
+ const ticked = state.strip.rows.filter((r) => r.at).length;
6859
+ bits.push(
6860
+ 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`
6861
+ );
6862
+ const held = state.strip.rows.filter((r) => r.lease === "held").length;
6863
+ if (held) bits.push(`${held} lease${held === 1 ? "" : "s"} held`);
6864
+ const expired = state.strip.rows.filter((r) => r.lease === "expired").length;
6865
+ if (expired) bits.push(`${expired} lease${expired === 1 ? "" : "s"} expired \u2014 the next round reclaims it`);
6866
+ if (state.strip.pendingGates) {
6867
+ bits.push(
6868
+ `${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)`
6869
+ );
6870
+ }
6871
+ return `Operating loop: ${loops} \u2014 ${bits.join("; ")}.`;
5036
6872
  }
5037
6873
 
5038
6874
  // src/adopt.ts
@@ -5115,16 +6951,21 @@ function classifyHealth(status) {
5115
6951
  if (HEALTHY.test(text)) return "healthy";
5116
6952
  return "unknown";
5117
6953
  }
5118
- function rec2(v) {
6954
+ function rec4(v) {
5119
6955
  return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
5120
6956
  }
5121
- function str2(v) {
6957
+ function str4(v) {
5122
6958
  return typeof v === "string" && v.length > 0 ? v : void 0;
5123
6959
  }
5124
6960
  function statusTree(o) {
5125
- const attrs = rec2(o.attributes);
6961
+ const attrs = rec4(o.attributes);
6962
+ if (!attrs) return void 0;
6963
+ return rec4(attrs.status) ?? attrs;
6964
+ }
6965
+ function specTree(o) {
6966
+ const attrs = rec4(o.attributes);
5126
6967
  if (!attrs) return void 0;
5127
- return rec2(attrs.status) ?? attrs;
6968
+ return rec4(attrs.spec) ?? attrs;
5128
6969
  }
5129
6970
  var ARGO_HEALTH = {
5130
6971
  Healthy: "healthy",
@@ -5150,9 +6991,9 @@ function argoErrorMessage(tree) {
5150
6991
  if (m) return m[2];
5151
6992
  continue;
5152
6993
  }
5153
- const type = str2(rec2(c)?.type);
6994
+ const type = str4(rec4(c)?.type);
5154
6995
  if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
5155
- const message = str2(rec2(c)?.message);
6996
+ const message = str4(rec4(c)?.message);
5156
6997
  if (message) return message;
5157
6998
  }
5158
6999
  return void 0;
@@ -5160,10 +7001,10 @@ function argoErrorMessage(tree) {
5160
7001
  function argoVerdict(o) {
5161
7002
  if (o.type !== "K8s::Argo::Application") return void 0;
5162
7003
  const tree = statusTree(o);
5163
- let health = str2(rec2(tree?.health)?.status);
5164
- let sync = str2(rec2(tree?.sync)?.status);
7004
+ let health = str4(rec4(tree?.health)?.status);
7005
+ let sync = str4(rec4(tree?.sync)?.status);
5165
7006
  if (!health && !sync) {
5166
- const word = str2(o.status);
7007
+ const word = str4(o.status);
5167
7008
  if (word === "READY") {
5168
7009
  health = "Healthy";
5169
7010
  sync = "Synced";
@@ -5189,16 +7030,16 @@ function readyFromConditions(tree) {
5189
7030
  const conditions = tree?.conditions;
5190
7031
  if (!Array.isArray(conditions)) return void 0;
5191
7032
  for (const c of conditions) {
5192
- const cond = rec2(c);
7033
+ const cond = rec4(c);
5193
7034
  if (!cond || cond.type !== "Ready") continue;
5194
- const status = str2(cond.status);
7035
+ const status = str4(cond.status);
5195
7036
  if (status !== "True" && status !== "False" && status !== "Unknown") continue;
5196
- return { status, reason: str2(cond.reason) };
7037
+ return { status, reason: str4(cond.reason) };
5197
7038
  }
5198
7039
  return void 0;
5199
7040
  }
5200
7041
  function readyFromChantConditions(o) {
5201
- const conditions = rec2(o.attributes)?.conditions;
7042
+ const conditions = rec4(o.attributes)?.conditions;
5202
7043
  if (!Array.isArray(conditions)) return void 0;
5203
7044
  for (const c of conditions) {
5204
7045
  if (typeof c !== "string") continue;
@@ -5210,7 +7051,7 @@ function readyFromChantConditions(o) {
5210
7051
  return void 0;
5211
7052
  }
5212
7053
  function readyFromStatusWord(o) {
5213
- const word = str2(o.status);
7054
+ const word = str4(o.status);
5214
7055
  if (!word || word === "PRESENT") return void 0;
5215
7056
  if (word === "READY") return { status: "True" };
5216
7057
  if (word === "NOT-READY") return { status: "False" };
@@ -5220,12 +7061,13 @@ function describeReady(ready) {
5220
7061
  return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
5221
7062
  }
5222
7063
  function revisionVerdict(tree) {
5223
- const applied = str2(tree?.lastAppliedRevision);
5224
- const attempted = str2(tree?.lastAttemptedRevision);
7064
+ const applied = str4(tree?.lastAppliedRevision);
7065
+ const attempted = str4(tree?.lastAttemptedRevision);
5225
7066
  if (!applied || !attempted || applied === attempted) return void 0;
5226
7067
  return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
5227
7068
  }
5228
7069
  var FLUX_TYPE_PREFIX = "K8s::Flux::";
7070
+ var FLUX_HELMREPOSITORY_TYPE = "K8s::Flux::HelmRepository";
5229
7071
  function fluxVerdict(o) {
5230
7072
  if (!o.type?.startsWith(FLUX_TYPE_PREFIX)) return void 0;
5231
7073
  const tree = statusTree(o);
@@ -5233,20 +7075,55 @@ function fluxVerdict(o) {
5233
7075
  if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
5234
7076
  if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
5235
7077
  if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
7078
+ if (o.type === FLUX_HELMREPOSITORY_TYPE && str4(specTree(o)?.type) === "oci") {
7079
+ return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
7080
+ }
5236
7081
  const conditions = tree?.conditions;
5237
7082
  const reconciling = Array.isArray(conditions) && conditions.some((c) => {
5238
- const cond = rec2(c);
7083
+ const cond = rec4(c);
5239
7084
  return cond?.type === "Reconciling" && cond.status === "True";
5240
7085
  });
5241
7086
  if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
5242
- if (!str2(o.status) || o.status === "PRESENT") {
7087
+ if (!str4(o.status) || o.status === "PRESENT") {
5243
7088
  return { health: "progressing", detail: "no Ready condition yet" };
5244
7089
  }
5245
7090
  return void 0;
5246
7091
  }
7092
+ var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
7093
+ function unhappyCondition(o, type) {
7094
+ const fromStrings = rec4(o.attributes)?.conditions;
7095
+ if (Array.isArray(fromStrings)) {
7096
+ for (const c of fromStrings) {
7097
+ if (typeof c === "string" && c.startsWith(type)) return c;
7098
+ }
7099
+ }
7100
+ const tree = statusTree(o);
7101
+ const raw = tree?.conditions;
7102
+ if (Array.isArray(raw)) {
7103
+ for (const c of raw) {
7104
+ const cond = rec4(c);
7105
+ if (!cond || cond.type !== type) continue;
7106
+ const status = str4(cond.status);
7107
+ const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
7108
+ if (!unhappy) continue;
7109
+ const reason = str4(cond.reason);
7110
+ const message = str4(cond.message);
7111
+ return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
7112
+ }
7113
+ }
7114
+ return void 0;
7115
+ }
7116
+ function deploymentVerdict(o) {
7117
+ if (o.type !== DEPLOYMENT_TYPE) return void 0;
7118
+ const damage = unhappyCondition(o, "ReplicaFailure") ?? unhappyCondition(o, "Progressing");
7119
+ if (damage) return { health: "degraded", detail: damage };
7120
+ const available = unhappyCondition(o, "Available");
7121
+ if (available) return { health: "progressing", detail: available };
7122
+ return void 0;
7123
+ }
5247
7124
  function classifyObservedHealth(observed) {
5248
7125
  if (!observed) return { health: "unknown" };
5249
- return argoVerdict(observed) ?? fluxVerdict(observed) ?? { health: classifyHealth(observed.status) };
7126
+ return argoVerdict(observed) ?? fluxVerdict(observed) ?? deploymentVerdict(observed) ?? { health: classifyHealth(observed.status) };
5250
7127
  }
5251
7128
 
5252
7129
  // src/apply.ts
@@ -5359,6 +7236,19 @@ var OpRunner = class {
5359
7236
  * before the first event lands so a reload between trigger and first event
5360
7237
  * doesn't show the PREVIOUS run's stale terminal state). */
5361
7238
  lastApplyProgress = initialApplyProgress;
7239
+ /** The run playhead (#284 item 2): the settled StepRecords of the last Op
7240
+ * `trigger()` started, plus the last `gateState` answer. Kept after the run
7241
+ * ends and across renders for the same reason `lastApplyProgress` is — a
7242
+ * client opening the ops lens mid-run (or after a reload) hydrates the
7243
+ * playhead instead of starting blank. In-process only: a run started
7244
+ * elsewhere is invisible here, and `chant run status` (src/server.ts's
7245
+ * `/api/ops/:name/status`) is what answers for one. */
7246
+ lastRunState = initialRunState;
7247
+ /** The operator strip (#234 join 3): the last `chant operator status --json`
7248
+ * read, plus the ConvergeOps the built project declares. Held here for the
7249
+ * reason `lastRunState` is — it is a durable status read, not a run, and a
7250
+ * client opening the ops lens hydrates from it instead of starting blank. */
7251
+ lastOperatorState = initialOperatorState;
5362
7252
  /** #195: re-point delegated writes at a switched project. The runner reads
5363
7253
  * `deps.projectDir` at trigger time, so this is the whole retarget. */
5364
7254
  retarget(projectDir) {
@@ -5373,13 +7263,113 @@ var OpRunner = class {
5373
7263
  get applyProgress() {
5374
7264
  return this.lastApplyProgress;
5375
7265
  }
7266
+ /** The run playhead's model (#284 item 2) — `initialRunState` until an Op has
7267
+ * been triggered this session. */
7268
+ get runState() {
7269
+ return this.lastRunState;
7270
+ }
7271
+ /**
7272
+ * Fold a `chant run status <name>` read (src/run-playhead.ts's
7273
+ * `readRunStatus`) into the playhead, and broadcast it.
7274
+ *
7275
+ * The gate is a workflow QUERY, not part of the record stream, so it can only
7276
+ * arrive this way. A read for an Op other than the one being watched is
7277
+ * returned to the caller but not folded — one playhead, one Op, and silently
7278
+ * re-pointing it at whatever was last polled would make the picture lie about
7279
+ * which run it shows. A read arriving before anything has run this session
7280
+ * adopts the Op: a gate pending on a run behold didn't start is exactly the
7281
+ * case the pending card exists for.
7282
+ */
7283
+ noteGate(op, result) {
7284
+ if (this.lastRunState.op !== null && this.lastRunState.op !== op) return this.lastRunState;
7285
+ const base = this.lastRunState.op === op ? this.lastRunState : { ...initialRunState, op, mode: "temporal" };
7286
+ const next = runGate(base, result);
7287
+ const changed = JSON.stringify(next) !== JSON.stringify(this.lastRunState);
7288
+ this.lastRunState = next;
7289
+ if (changed) this.emitRun();
7290
+ return this.lastRunState;
7291
+ }
5376
7292
  /**
5377
7293
  * Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
5378
7294
  * sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
5379
7295
  * the primary. Returns true if it started, false if busy.
7296
+ *
7297
+ * `temporal` — pass `--temporal` so the run gets the durable runtime. Callers
7298
+ * set it from the Op's declared gate: chant refuses a gated Op outright in
7299
+ * local mode ("gates and schedules need a durable runtime"), so without the
7300
+ * flag a gated Op could never run from behold at all.
7301
+ *
7302
+ * Either way the run is asked for structured per-step records (#284 item 2,
7303
+ * chant#1676) — `--progress-json` streams one NDJSON `StepRecord` per settled
7304
+ * step on the durable path; `--json` prints the whole `OpRunResult` at the end
7305
+ * on the local one. Same record shape, which is what lets one playhead render
7306
+ * both. `--json` replaces chant's own human per-step summary on the local
7307
+ * path, so each parsed record is re-emitted to the `op` now-line
7308
+ * ({@link recordLine}) and the operator loses no narration.
7309
+ */
7310
+ trigger(name, opEnv, cwd, temporal) {
7311
+ if (this.current) return false;
7312
+ const mode = temporal ? "temporal" : "local";
7313
+ const args = temporal ? ["run", name, "--temporal", "--progress-json"] : ["run", name, "--json"];
7314
+ this.lastRunState = runStarted(name, mode, (/* @__PURE__ */ new Date()).toISOString());
7315
+ this.emitRun();
7316
+ return this.start(
7317
+ args,
7318
+ name,
7319
+ opEnv,
7320
+ cwd,
7321
+ (line) => {
7322
+ const records = parseRunProgressLine(line);
7323
+ if (!records) return false;
7324
+ for (const record of records) {
7325
+ this.lastRunState = runRecord(this.lastRunState, record);
7326
+ this.deps.broadcaster.emit("op", recordLine(record));
7327
+ }
7328
+ this.emitRun();
7329
+ return true;
7330
+ },
7331
+ (code) => {
7332
+ this.lastRunState = runEnded(this.lastRunState, code, (/* @__PURE__ */ new Date()).toISOString());
7333
+ this.emitRun();
7334
+ }
7335
+ );
7336
+ }
7337
+ emitRun() {
7338
+ this.deps.broadcaster.emit("run", JSON.stringify(this.lastRunState));
7339
+ }
7340
+ /** The operator strip's model (#234 join 3) — `initialOperatorState` until the
7341
+ * ops lens has been opened once. */
7342
+ get operatorState() {
7343
+ return this.lastOperatorState;
7344
+ }
7345
+ /**
7346
+ * Record which ConvergeOps the built project declares, read from the emitted
7347
+ * op.json (src/operator.ts's `declaredConvergeOps`) — no subprocess, and the
7348
+ * thing that gates the strip. Broadcast on a change so a project rebuilt (or
7349
+ * switched, #195) under an open tab grows or loses its strip without a reload.
7350
+ */
7351
+ noteDeclaredConvergeOps(declared) {
7352
+ return this.foldOperator({ ...this.lastOperatorState, declared });
7353
+ }
7354
+ /**
7355
+ * Fold a `chant operator status --json` read (src/operator.ts's
7356
+ * `readOperatorStatus`) into the strip, and broadcast it.
7357
+ *
7358
+ * Broadcast only a CHANGED answer, for `noteGate`'s reason: a client asks when
7359
+ * it opens the ops lens and re-pulls the lens on the broadcast, so an
7360
+ * unchanged answer that still broadcast would have the two chasing each other.
5380
7361
  */
5381
- trigger(name, opEnv, cwd) {
5382
- return this.start(["run", name], name, opEnv, cwd);
7362
+ noteOperator(result, at = (/* @__PURE__ */ new Date()).toISOString()) {
7363
+ return this.foldOperator(operatorRead(this.lastOperatorState, result, at));
7364
+ }
7365
+ /** Compare-and-broadcast, ignoring `readAt` — the instant behold asked changes
7366
+ * on every poll and is not itself news; broadcasting on it would make the
7367
+ * interval a repaint loop. */
7368
+ foldOperator(next) {
7369
+ const same = JSON.stringify({ ...next, readAt: null }) === JSON.stringify({ ...this.lastOperatorState, readAt: null });
7370
+ this.lastOperatorState = next;
7371
+ if (!same) this.deps.broadcaster.emit("operator", JSON.stringify(next));
7372
+ return this.lastOperatorState;
5383
7373
  }
5384
7374
  /**
5385
7375
  * Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
@@ -5511,8 +7501,12 @@ var OpRunner = class {
5511
7501
  * fully handled that line (apply()'s progress-JSON parsing) — `start()` then
5512
7502
  * skips its own `op`/`pr` broadcast for that one line, leaving every other
5513
7503
  * line's raw-log fallback untouched.
7504
+ *
7505
+ * `onExit` (the run playhead, #284 item 2) sees the exit code the moment the
7506
+ * process ends — the only signal that separates "the run finished" from "the
7507
+ * stream stopped", and the reason the playhead never has to infer completion.
5514
7508
  */
5515
- start(args, label, opEnv, cwd, onLine) {
7509
+ start(args, label, opEnv, cwd, onLine, onExit) {
5516
7510
  if (this.current) return false;
5517
7511
  const { projectDir, broadcaster } = this.deps;
5518
7512
  broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
@@ -5525,6 +7519,7 @@ var OpRunner = class {
5525
7519
  this.current = label;
5526
7520
  void op.done.then((code) => {
5527
7521
  broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
7522
+ onExit?.(code);
5528
7523
  this.current = null;
5529
7524
  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)}`));
5530
7525
  });
@@ -5534,24 +7529,24 @@ var OpRunner = class {
5534
7529
 
5535
7530
  // src/substrates.ts
5536
7531
  import { spawn as spawn4 } from "node:child_process";
5537
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
5538
- import { join as join12 } from "node:path";
7532
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
7533
+ import { join as join15 } from "node:path";
5539
7534
  import { platform } from "node:os";
5540
7535
  var DOCKER_DOWN_DETAIL = "docker is down";
5541
7536
  function probe(cmd, args) {
5542
- return new Promise((resolve8) => {
7537
+ return new Promise((resolve9) => {
5543
7538
  let out = "";
5544
7539
  let proc;
5545
7540
  try {
5546
7541
  proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5547
7542
  } catch {
5548
- resolve8({ code: 127, out: "" });
7543
+ resolve9({ code: 127, out: "" });
5549
7544
  return;
5550
7545
  }
5551
7546
  proc.stdout.on("data", (d) => out += d);
5552
7547
  proc.stderr.on("data", (d) => out += d);
5553
- proc.on("error", () => resolve8({ code: 127, out }));
5554
- proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
7548
+ proc.on("error", () => resolve9({ code: 127, out }));
7549
+ proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
5555
7550
  });
5556
7551
  }
5557
7552
  async function dockerAvailable(run3) {
@@ -5564,11 +7559,11 @@ async function dockerRunning(run3, nameFilter) {
5564
7559
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
5565
7560
  }
5566
7561
  function scriptBringUp(projectDir, relPath, label) {
5567
- return existsSync10(join12(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
7562
+ return existsSync10(join15(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5568
7563
  }
5569
7564
  function projectLexicons(projectDir) {
5570
7565
  try {
5571
- const src = readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8");
7566
+ const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
5572
7567
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
5573
7568
  if (!m) return [];
5574
7569
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -5620,7 +7615,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5620
7615
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
5621
7616
  ];
5622
7617
  for (const [name, label, marker, script] of forges) {
5623
- if (!existsSync10(join12(projectDir, marker))) continue;
7618
+ if (!existsSync10(join15(projectDir, marker))) continue;
5624
7619
  const c = docker ? await dockerRunning(run3, name) : [];
5625
7620
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
5626
7621
  subs.push({
@@ -5654,7 +7649,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5654
7649
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
5655
7650
  });
5656
7651
  }
5657
- if (existsSync10(join12(projectDir, ".github", "workflows"))) {
7652
+ if (existsSync10(join15(projectDir, ".github", "workflows"))) {
5658
7653
  const gh = await run3("gh", ["auth", "status"]);
5659
7654
  const ready = gh.code === 0;
5660
7655
  subs.push({
@@ -5667,7 +7662,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5667
7662
  if (lexicons.includes("temporal")) {
5668
7663
  let hasProfiles = false;
5669
7664
  try {
5670
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8"));
7665
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8"));
5671
7666
  } catch {
5672
7667
  }
5673
7668
  subs.push({
@@ -5770,165 +7765,9 @@ async function openRollbackBranches(projectDir, env) {
5770
7765
  }
5771
7766
  }
5772
7767
 
5773
- // src/estate.ts
5774
- import { statSync as statSync3 } from "node:fs";
5775
- import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
5776
- import { composeStacks, shortStackNames } from "@intentius/pinhole";
5777
- async function composeEstate(projectDirs, opts = {}) {
5778
- const names = shortStackNames(projectDirs);
5779
- const stacks = await Promise.all(
5780
- projectDirs.map(async (dir, i) => ({ name: names[i], ir: await graphIr(dir, opts) }))
5781
- );
5782
- return composeStacks(stacks);
5783
- }
5784
- var firstLine2 = (e) => {
5785
- const msg = e instanceof Error ? e.message : String(e);
5786
- const m = msg.match(/exited \d+:\s*([\s\S]*)/);
5787
- return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
5788
- };
5789
- function namespaceRuntimeOwners(name, ir) {
5790
- const own = new Set(ir.nodes.map((n) => n.id));
5791
- for (const n of ir.nodes) {
5792
- if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
5793
- }
5794
- return ir;
5795
- }
5796
- var NAMESPACE_JOIN_FLOOR = "0.44.5";
5797
- var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
5798
- var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
5799
- var realIsDir = (p) => {
5800
- try {
5801
- return statSync3(p).isDirectory();
5802
- } catch {
5803
- return false;
5804
- }
5805
- };
5806
- function declaredNamespaceBindings(ir) {
5807
- const out = [];
5808
- for (const n of ir.nodes) {
5809
- if (n.kind !== "K8s::Flux::Kustomization") continue;
5810
- const spec = rec3(n.attrs?.spec);
5811
- const namespace = str3(spec?.targetNamespace);
5812
- const path = str3(spec?.path);
5813
- if (namespace && path) out.push({ path, namespace });
5814
- }
5815
- return out;
5816
- }
5817
- function awaitsNamespaceBinding(ir) {
5818
- const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
5819
- if (namespaced.length === 0) return false;
5820
- return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
5821
- }
5822
- function declaredSegments(path) {
5823
- if (path.startsWith("/")) return void 0;
5824
- const segs = path.split("/").filter((s) => s !== "" && s !== ".");
5825
- if (segs.length === 0 || segs.includes("..")) return void 0;
5826
- return segs;
5827
- }
5828
- function pathAlignment(declared, dir, isDir = realIsDir) {
5829
- const want = declaredSegments(declared);
5830
- if (!want) return 0;
5831
- const root = resolve3(dir);
5832
- const have = root.split(sep2).filter(Boolean);
5833
- for (let k = Math.min(want.length, have.length); k >= 1; k--) {
5834
- if (!have.slice(-k).every((s, i) => s === want[i])) continue;
5835
- if (!isDir(join13(root, ...want.slice(k)))) continue;
5836
- return k;
5837
- }
5838
- return 0;
5839
- }
5840
- function joinNamespaceBindings(members, isDir = realIsDir) {
5841
- const bindings = members.flatMap(
5842
- (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
5843
- );
5844
- if (bindings.length === 0) return [];
5845
- const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
5846
- if (awaiting.length === 0) return [];
5847
- const best = /* @__PURE__ */ new Map();
5848
- for (const b of bindings) {
5849
- let winner;
5850
- let tied = false;
5851
- for (const m of awaiting) {
5852
- if (m.dir === b.declaredBy) continue;
5853
- const score = pathAlignment(b.path, m.dir, isDir);
5854
- if (score === 0) continue;
5855
- if (!winner || score > winner.score) {
5856
- winner = { dir: m.dir, score };
5857
- tied = false;
5858
- } else if (score === winner.score) {
5859
- tied = true;
5860
- }
5861
- }
5862
- if (!winner || tied) continue;
5863
- const prev = best.get(winner.dir);
5864
- if (prev === "ambiguous") continue;
5865
- const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
5866
- if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
5867
- else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
5868
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
5869
- }
5870
- return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
5871
- }
5872
- async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
5873
- if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
5874
- const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
5875
- const irs = await Promise.all(projectDirs.map((dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)));
5876
- const scopes = /* @__PURE__ */ new Map();
5877
- for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
5878
- if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
5879
- scopes.set(j.dir, j.namespace);
5880
- }
5881
- return scopes;
5882
- }
5883
- function withoutJoinedMembers(nodes, joined) {
5884
- if (joined.length === 0) return [...nodes];
5885
- return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
5886
- }
5887
- async function composeEstateOverlay(projectDirs, opts, classify) {
5888
- const names = shortStackNames(projectDirs);
5889
- const unobserved = [];
5890
- const dropped = [];
5891
- const joined = [];
5892
- const stacks = new Array(projectDirs.length);
5893
- const scopes = await estateNamespaceScopes(projectDirs, opts);
5894
- await Promise.all(
5895
- projectDirs.map(async (dir, i) => {
5896
- const name = names[i];
5897
- const namespace = scopes.get(dir);
5898
- try {
5899
- const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
5900
- stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
5901
- if (namespace) joined.push({ name, namespace });
5902
- } catch (err) {
5903
- const reason = firstLine2(err);
5904
- try {
5905
- const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
5906
- const src = await graphIr(dir, srcOpts);
5907
- for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
5908
- stacks[i] = { name, ir: src };
5909
- unobserved.push({ name, reason });
5910
- } catch (err2) {
5911
- dropped.push({ name, reason: firstLine2(err2) });
5912
- }
5913
- }
5914
- })
5915
- );
5916
- const present = stacks.filter((s) => !!s);
5917
- return {
5918
- ir: composeStacks(present),
5919
- observed: present.length - unobserved.length,
5920
- total: projectDirs.length,
5921
- unobserved,
5922
- dropped,
5923
- // Concurrent reads finish in whatever order the clusters answer; the note
5924
- // this feeds should read the same twice.
5925
- joined: joined.sort((a, b) => a.name.localeCompare(b.name))
5926
- };
5927
- }
5928
-
5929
7768
  // src/events.ts
5930
7769
  import { watch, existsSync as existsSync11 } from "node:fs";
5931
- import { join as join14 } from "node:path";
7770
+ import { join as join16 } from "node:path";
5932
7771
  var Broadcaster = class {
5933
7772
  listeners = /* @__PURE__ */ new Set();
5934
7773
  subscribe(fn) {
@@ -5946,7 +7785,7 @@ var Broadcaster = class {
5946
7785
  };
5947
7786
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
5948
7787
  function watchSource(projectDir, onChange, debounceMs = 200) {
5949
- const dir = existsSync11(join14(projectDir, "src")) ? join14(projectDir, "src") : projectDir;
7788
+ const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
5950
7789
  let timer;
5951
7790
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
5952
7791
  const name = typeof file === "string" ? file : "";
@@ -5959,6 +7798,12 @@ function watchSource(projectDir, onChange, debounceMs = 200) {
5959
7798
  watcher.close();
5960
7799
  };
5961
7800
  }
7801
+ function watchSources(projectDirs, onChange, debounceMs = 200) {
7802
+ const stops = projectDirs.map((dir) => watchSource(dir, () => onChange(dir), debounceMs));
7803
+ return () => {
7804
+ for (const stop of stops) stop();
7805
+ };
7806
+ }
5962
7807
 
5963
7808
  // src/poll.ts
5964
7809
  function digestOf(nodes) {
@@ -5986,18 +7831,22 @@ function changedLexicons(prev, next) {
5986
7831
  }
5987
7832
  function startDriftPoll(opts) {
5988
7833
  let stopped = false;
5989
- let last;
7834
+ const last = /* @__PURE__ */ new Map();
5990
7835
  let timer;
5991
7836
  const tick = async () => {
5992
- try {
5993
- const digests = driftDigestsByLexicon(await opts.query());
5994
- if (last !== void 0) {
5995
- const moved = changedLexicons(last, digests);
5996
- if (moved.length) opts.onChange(moved);
7837
+ for (const member of opts.members) {
7838
+ if (stopped) return;
7839
+ try {
7840
+ const digests = driftDigestsByLexicon(await member.query());
7841
+ const prev = last.get(member.dir);
7842
+ if (prev !== void 0) {
7843
+ const moved = changedLexicons(prev, digests);
7844
+ if (moved.length) opts.onChange(member.dir, moved);
7845
+ }
7846
+ last.set(member.dir, digests);
7847
+ } catch (err) {
7848
+ opts.onError?.(member.dir, err);
5997
7849
  }
5998
- last = digests;
5999
- } catch (err) {
6000
- opts.onError?.(err);
6001
7850
  }
6002
7851
  if (!stopped) timer = setTimeout(tick, opts.intervalMs);
6003
7852
  };
@@ -6185,8 +8034,8 @@ function renderLanes(frames, summaries) {
6185
8034
  status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
6186
8035
  lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
6187
8036
  }));
6188
- 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);
6189
- return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
8037
+ 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);
8038
+ return doc.includes("</body>") ? doc.replace("</body>", `${strip3}</body>`) : doc + strip3;
6190
8039
  }
6191
8040
 
6192
8041
  // src/emulator.ts
@@ -6224,13 +8073,13 @@ async function emulatorDown(projectDir) {
6224
8073
  }
6225
8074
 
6226
8075
  // src/demos.ts
6227
- import { readFileSync as readFileSync12, existsSync as existsSync12, cpSync } from "node:fs";
6228
- import { join as join15, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
8076
+ import { readFileSync as readFileSync13, existsSync as existsSync12, cpSync } from "node:fs";
8077
+ import { join as join17, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
6229
8078
  import { spawn as spawn5, spawnSync } from "node:child_process";
6230
8079
  function loadDemoRegistry(pkgRoot2) {
6231
8080
  let raw;
6232
8081
  try {
6233
- raw = JSON.parse(readFileSync12(join15(pkgRoot2, "demos.json"), "utf8"));
8082
+ raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
6234
8083
  } catch {
6235
8084
  return [];
6236
8085
  }
@@ -6270,22 +8119,22 @@ function fetchesFromNetwork(entry) {
6270
8119
  return entry.source === "git";
6271
8120
  }
6272
8121
  function demoTargetDir(entry, cwd = process.cwd()) {
6273
- const legacy = resolve4(cwd, "behold-demo");
8122
+ const legacy = resolve5(cwd, "behold-demo");
6274
8123
  if (entry.name === "writes" && existsSync12(legacy)) return legacy;
6275
- return resolve4(cwd, "behold-demos", entry.name);
8124
+ return resolve5(cwd, "behold-demos", entry.name);
6276
8125
  }
6277
8126
  async function loadDemo(entry, opts) {
6278
8127
  const { pkgRoot: pkgRoot2, target } = opts;
6279
8128
  const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6280
8129
  if (!existsSync12(target)) {
6281
8130
  if (entry.source === "bundled") {
6282
- const bundled = join15(pkgRoot2, entry.dir);
8131
+ const bundled = join17(pkgRoot2, entry.dir);
6283
8132
  if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6284
8133
  say(`copying to ${target} (it's yours \u2014 edit it)`);
6285
8134
  try {
6286
8135
  cpSync(bundled, target, {
6287
8136
  recursive: true,
6288
- filter: (src) => !relative2(bundled, src).split(sep3).includes("node_modules")
8137
+ filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
6289
8138
  });
6290
8139
  } catch (err) {
6291
8140
  return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -6299,7 +8148,7 @@ async function loadDemo(entry, opts) {
6299
8148
  } else {
6300
8149
  say(`reusing ${target}`);
6301
8150
  }
6302
- if (existsSync12(join15(target, "package.json")) && !existsSync12(join15(target, "node_modules"))) {
8151
+ if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
6303
8152
  say("npm install\u2026");
6304
8153
  if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6305
8154
  return { ok: false, error: `npm install failed in ${target}` };
@@ -6311,7 +8160,7 @@ async function loadDemo(entry, opts) {
6311
8160
  return { ok: false, error: `setup failed (${entry.setup})` };
6312
8161
  }
6313
8162
  }
6314
- return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join15(target, d)) : [target] };
8163
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join17(target, d)) : [target] };
6315
8164
  }
6316
8165
  function runStep(cmd, args, opts = {}) {
6317
8166
  return new Promise((res) => {
@@ -6322,8 +8171,8 @@ function runStep(cmd, args, opts = {}) {
6322
8171
  }
6323
8172
 
6324
8173
  // src/server.ts
6325
- var pkgRoot = join16(dirname4(fileURLToPath3(import.meta.url)), "..");
6326
- var webRoot = join16(pkgRoot, "web");
8174
+ var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
8175
+ var webRoot = join18(pkgRoot, "web");
6327
8176
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6328
8177
  function optsFromQuery(url, tierEnvVar, projectDir) {
6329
8178
  const q = url.searchParams;
@@ -6372,8 +8221,8 @@ function tierFailure(tier, message) {
6372
8221
  }
6373
8222
  function beholdVersion() {
6374
8223
  try {
6375
- const here = dirname4(fileURLToPath3(import.meta.url));
6376
- return JSON.parse(readFileSync13(join16(here, "..", "package.json"), "utf8")).version ?? "unknown";
8224
+ const here = dirname5(fileURLToPath3(import.meta.url));
8225
+ return JSON.parse(readFileSync14(join18(here, "..", "package.json"), "utf8")).version ?? "unknown";
6377
8226
  } catch {
6378
8227
  return "unknown";
6379
8228
  }
@@ -6418,7 +8267,9 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
6418
8267
  }
6419
8268
  }
6420
8269
  function carveRoutes(app, reportPath, demo) {
6421
- const load = () => readCarveReport(reportPath, (p) => readFileSync13(p, "utf8"));
8270
+ const load = () => readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
8271
+ const carveDirs = [dirname5(reportPath), ...demo ? [demo.out] : []];
8272
+ const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
6422
8273
  const demoBlock = () => carveWriteBlock(demo);
6423
8274
  const demoInfo = () => {
6424
8275
  if (!demo) return null;
@@ -6430,8 +8281,8 @@ function carveRoutes(app, reportPath, demo) {
6430
8281
  project: demo.project,
6431
8282
  out: demo.out,
6432
8283
  // Paths the UI can print without leaking the operator's whole home dir.
6433
- outLabel: relative3(demo.root, demo.out).split(sep4).join("/"),
6434
- fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
8284
+ outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
8285
+ fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
6435
8286
  runnable: !block,
6436
8287
  ...block ? { reason: block } : {},
6437
8288
  // The live tier, when this boot armed it: the scratch endpoint the
@@ -6448,7 +8299,7 @@ function carveRoutes(app, reportPath, demo) {
6448
8299
  let appGraph;
6449
8300
  const appGraphOnce = () => {
6450
8301
  if (!demo) return Promise.resolve(null);
6451
- appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative3(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
8302
+ appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
6452
8303
  return appGraph;
6453
8304
  };
6454
8305
  app.get("/api/graph", async (c) => {
@@ -6456,10 +8307,21 @@ function carveRoutes(app, reportPath, demo) {
6456
8307
  if (!parsed.ok) return c.json(parsed.refusal, 422);
6457
8308
  const tfIr = carveReportToIr(parsed.report);
6458
8309
  const appSide = await appGraphOnce();
8310
+ const states = carveStates();
8311
+ const shorten = demo ? (p) => shortenIn(p, demo.root) : (p) => p;
8312
+ const single = () => {
8313
+ const split = splitCarveState(tfIr, states, { shorten });
8314
+ return bandGraduated(split.tf, split.graduated);
8315
+ };
6459
8316
  const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
6460
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6461
- appTitle: `${appSide.label} \u2014 chant`
6462
- }) : { ...renderBanded(tfIr), ir: tfIr };
8317
+ tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8318
+ appTitle: `${appSide.label} \u2014 chant`,
8319
+ carved: states,
8320
+ shorten
8321
+ }) : (() => {
8322
+ const banded = single();
8323
+ return { ...renderBanded(banded), ir: banded };
8324
+ })();
6463
8325
  return c.json({
6464
8326
  ir,
6465
8327
  svg,
@@ -6471,7 +8333,7 @@ function carveRoutes(app, reportPath, demo) {
6471
8333
  carve: true,
6472
8334
  // A demo whose own advisor run failed says so on the statusbar, not
6473
8335
  // only in the terminal the viewer isn't looking at.
6474
- note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
8336
+ note: carveNote(parsed.report, tfIr) + carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6475
8337
  }
6476
8338
  });
6477
8339
  });
@@ -6503,14 +8365,22 @@ function carveRoutes(app, reportPath, demo) {
6503
8365
  }
6504
8366
  const tfIr = carveReportToIr(parsed.report);
6505
8367
  const html = renderCarveMorph(tfIr, appSide.ir, [select], {
6506
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8368
+ tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6507
8369
  appTitle: `${appSide.label} \u2014 chant`,
6508
- title: `carve \u2014 ${select}`
8370
+ title: `carve \u2014 ${select}`,
8371
+ carved: carveStates(),
8372
+ shorten: (p) => shortenIn(p, demo.root)
6509
8373
  });
6510
8374
  return c.html(html);
6511
8375
  });
6512
8376
  app.get("/api/project", (c) => {
6513
8377
  const parsed = load();
8378
+ const total = parsed.ok ? parsed.report.count ?? parsed.report.resources.length : null;
8379
+ const state = carveStatePayload(
8380
+ carveStates(),
8381
+ total,
8382
+ () => demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {}
8383
+ );
6514
8384
  return c.json({
6515
8385
  projectDir: reportPath,
6516
8386
  recents: [],
@@ -6524,11 +8394,12 @@ function carveRoutes(app, reportPath, demo) {
6524
8394
  carve: parsed.ok ? {
6525
8395
  report: reportPath,
6526
8396
  from: parsed.report.from ?? null,
6527
- count: parsed.report.count ?? parsed.report.resources.length,
8397
+ count: total,
6528
8398
  bands: parsed.report.bands ?? {},
6529
8399
  advisory: parsed.report.advisory ?? null,
6530
- demo: demoInfo()
6531
- } : { report: reportPath, demo: demoInfo() }
8400
+ demo: demoInfo(),
8401
+ state
8402
+ } : { report: reportPath, demo: demoInfo(), state }
6532
8403
  });
6533
8404
  });
6534
8405
  const runStep2 = async (c, run3) => {
@@ -6571,6 +8442,7 @@ function carveRoutes(app, reportPath, demo) {
6571
8442
  };
6572
8443
  app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
6573
8444
  app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
8445
+ app.post("/api/carve/observe", (c) => runStep2(c, runCarveObserve));
6574
8446
  app.post("/api/carve/plan", async (c) => {
6575
8447
  const block = demoBlock();
6576
8448
  if (block) {
@@ -6637,7 +8509,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6637
8509
  // of starting blank — the `apply` SSE event (below) carries every update
6638
8510
  // after that. `status: "idle"` (initialApplyProgress) when nothing has
6639
8511
  // applied yet this session.
6640
- applyProgress: runner.applyProgress
8512
+ applyProgress: runner.applyProgress,
8513
+ // The run playhead (#284 item 2): the settled StepRecords of the Op this
8514
+ // session last triggered, so a client opening (or reloading) mid-run
8515
+ // hydrates the playhead instead of starting blank — the `run` SSE event
8516
+ // carries every update after that. `status: "idle"` when nothing has run.
8517
+ runState: runner.runState,
8518
+ // #234 join 3: the operator strip's last known model, so a client that
8519
+ // opens (or reloads) hydrates the strip instead of starting blank — same
8520
+ // reason `runState` is here. `declared: []` until the ops lens has been
8521
+ // opened once, since that is where the op.json read happens.
8522
+ operatorState: runner.operatorState
6641
8523
  })
6642
8524
  );
6643
8525
  app.post("/api/ops/:name/run", (c) => {
@@ -6647,7 +8529,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6647
8529
  if (!info) {
6648
8530
  return c.json({ error: `no Op named "${name}" in the estate` }, 404);
6649
8531
  }
6650
- if (!runner.trigger(name, info.env, info.dir)) {
8532
+ if (!runner.trigger(name, info.env, info.dir, Boolean(info.gate))) {
6651
8533
  return c.json({ error: `an Op is already running (${runner.running})` }, 409);
6652
8534
  }
6653
8535
  return c.json({ started: true, name });
@@ -6678,8 +8560,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6678
8560
  return c.json({ started: true, name, ran: label });
6679
8561
  });
6680
8562
  app.post("/api/local/reset", (c) => {
6681
- const down = join16(cfg.projectDir, "scripts/local/local-down.sh");
6682
- const up = join16(cfg.projectDir, "scripts/local/local-up.sh");
8563
+ const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
8564
+ const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
6683
8565
  if (!existsSync13(down) || !existsSync13(up)) {
6684
8566
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
6685
8567
  }
@@ -6688,11 +8570,67 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6688
8570
  }
6689
8571
  return c.json({ started: true, ran: "local-down + local-up" });
6690
8572
  });
8573
+ app.get("/api/ops/:name/status", async (c) => {
8574
+ const name = c.req.param("name");
8575
+ const info = estateOps().find((o) => o.name === name);
8576
+ const run3 = await runChantRaw(["run", "status", name, "--temporal"], info?.dir ?? cfg.projectDir);
8577
+ const result = readRunStatus(name, run3);
8578
+ const runState = runner.noteGate(name, result);
8579
+ if (!result.ok) return c.json({ ...result.refusal, runState }, 422);
8580
+ return c.json({ ...result.read, runState });
8581
+ });
8582
+ const convergeDirs = () => {
8583
+ const dirs = [];
8584
+ const declared = [];
8585
+ for (const f of discoverOpIrs(estateDirs)) {
8586
+ const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
8587
+ if (!parsed.ok) continue;
8588
+ const mine = declaredConvergeOps([parsed.ir]);
8589
+ if (!mine.length) continue;
8590
+ declared.push(...mine);
8591
+ if (!dirs.includes(f.dir)) dirs.push(f.dir);
8592
+ }
8593
+ return { dirs, declared };
8594
+ };
8595
+ app.get("/api/operator/status", async (c) => {
8596
+ const { dirs, declared } = convergeDirs();
8597
+ runner.noteDeclaredConvergeOps(declared);
8598
+ if (!dirs.length) {
8599
+ const state2 = runner.noteOperator({
8600
+ ok: false,
8601
+ refusal: {
8602
+ error: "This project declares no operating loop \u2014 no emitted Op carries chant's `Converge` search attribute.",
8603
+ code: "no-operator",
8604
+ remedy: HOW_TO_DECLARE
8605
+ }
8606
+ });
8607
+ return c.json({ ...state2, operator: state2 }, 200);
8608
+ }
8609
+ const rows = [];
8610
+ for (const dir of dirs) {
8611
+ const run3 = await runChantRaw(["operator", "status", "--json"], dir);
8612
+ const result = readOperatorStatus(run3);
8613
+ if (!result.ok) return c.json({ ...result.refusal, operator: runner.noteOperator(result) }, 422);
8614
+ rows.push(...result.rows);
8615
+ }
8616
+ const state = runner.noteOperator({ ok: true, rows });
8617
+ return c.json({ ...state, operator: state });
8618
+ });
8619
+ app.post("/api/operator/approve/:op/:gate", async (c) => {
8620
+ const { op, gate } = c.req.param();
8621
+ const info = estateOps().find((o) => o.name === op);
8622
+ broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
8623
+ const { code, stderr, stdout } = await runChantRaw(["approve", op, gate], info?.dir ?? cfg.projectDir);
8624
+ if (code !== 0) {
8625
+ return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
8626
+ }
8627
+ return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
8628
+ });
6691
8629
  app.post("/api/ops/:name/signal/:gate", async (c) => {
6692
8630
  const { name, gate } = c.req.param();
6693
8631
  const info = estateOps().find((o) => o.name === name);
6694
8632
  broadcaster.emit("op", `\u270E signal ${name} ${gate}`);
6695
- const { code, stderr } = await runChantRaw(["run", "signal", name, gate], info?.dir ?? cfg.projectDir);
8633
+ const { code, stderr } = await runChantRaw(["run", "signal", name, gate, "--temporal"], info?.dir ?? cfg.projectDir);
6696
8634
  if (code !== 0) return c.json({ error: stderr.trim() || `signal exited ${code}` }, 500);
6697
8635
  return c.json({ signalled: true });
6698
8636
  });
@@ -6714,8 +8652,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6714
8652
  const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
6715
8653
  const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
6716
8654
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
8655
+ const emittedOps = discoverOpIrs(estateDirs).length;
8656
+ const carveState = carveStatePayload(readCarveStates(estateDirs));
6717
8657
  return c.json({
6718
8658
  projectDir: cfg.projectDir,
8659
+ ...carveState.manifests ? { carve: { state: carveState } } : {},
6719
8660
  // #195: the full estate composition (multi-project serves) and the
6720
8661
  // switcher's recents, so the SPA's project section can show what's
6721
8662
  // loaded and offer where to go. Recents exclude nothing here — the SPA
@@ -6746,6 +8687,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6746
8687
  // the names — `graphPath` (chant.ts) resolves a picked name to its `src`
6747
8688
  // server-side; the SPA never needs the path.
6748
8689
  ...stacks?.length ? { stacks: stacks.map((s) => s.name) } : {},
8690
+ // The ops lens stop (#284), gated exactly like `tiers`/`stacks` above:
8691
+ // present only when the estate has actually emitted Ops, so a project on
8692
+ // a chant < 0.50, or one that has never been built, grows no dead zoom
8693
+ // stop and no dead ⌘K entry. The count, not a bare flag — the SPA shows
8694
+ // it, and "ops: 0" is not a thing this can ever say.
8695
+ ...emittedOps ? { ops: emittedOps } : {},
6749
8696
  targets: deployTargets(lexicons, k8sTarget),
6750
8697
  // Where the k8s binding came from, so the SPA never implies behold chose
6751
8698
  // it (#106). Absent for a project with no k8s lexicon or no resolvable
@@ -6757,9 +8704,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6757
8704
  app.post("/api/project/open", async (c) => {
6758
8705
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
6759
8706
  const body = await c.req.json().catch(() => ({}));
6760
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
8707
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : "";
6761
8708
  if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6762
- if (!existsSync13(join16(dir, "chant.config.ts"))) {
8709
+ if (!existsSync13(join18(dir, "chant.config.ts"))) {
6763
8710
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
6764
8711
  }
6765
8712
  switchServedProject([dir]);
@@ -6779,7 +8726,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6779
8726
  };
6780
8727
  app.post("/api/project/reveal", async (c) => {
6781
8728
  const body = await c.req.json().catch(() => ({}));
6782
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : cfg.projectDir;
8729
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : cfg.projectDir;
6783
8730
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
6784
8731
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
6785
8732
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -6847,7 +8794,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6847
8794
  const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
6848
8795
  if (!loaded.ok) return c.json({ error: loaded.error }, 500);
6849
8796
  const dirs = loaded.serveDirs;
6850
- if (!existsSync13(join16(dirs[0], "chant.config.ts"))) {
8797
+ if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
6851
8798
  return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
6852
8799
  }
6853
8800
  if (entry.serve.local) {
@@ -6945,7 +8892,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6945
8892
  path: "/api/demos/open",
6946
8893
  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)"
6947
8894
  },
6948
- { 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" },
8895
+ { 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" },
6949
8896
  ...cfg.carveReport ? [
6950
8897
  { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
6951
8898
  {
@@ -6967,14 +8914,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6967
8914
  { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
6968
8915
  { method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
6969
8916
  { method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
6970
- { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
8917
+ { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress + run playhead" },
8918
+ { method: "GET", path: "/api/ops/:name/status", desc: "an Op's durable run status + pending gate (chant run status)" },
8919
+ { method: "GET", path: "/api/operator/status", desc: "the operating loop's strip: last tick per ConvergeOp + lease + pending converge gates" },
6971
8920
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
6972
8921
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
6973
- { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
8922
+ { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
6974
8923
  { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
6975
8924
  { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
6976
8925
  { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
6977
- { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
8926
+ { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
8927
+ { 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)" },
6978
8928
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
6979
8929
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
6980
8930
  { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
@@ -6986,6 +8936,58 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6986
8936
  const url = new URL(c.req.url);
6987
8937
  const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
6988
8938
  try {
8939
+ if (url.searchParams.get("ops") === "1") {
8940
+ const files = discoverOpIrs(estateDirs);
8941
+ if (!files.length) {
8942
+ return c.json(
8943
+ {
8944
+ error: "this project has no emitted Ops \u2014 nothing under dist/ops/<name>/op.json",
8945
+ code: "op-ir",
8946
+ remedy: HOW_TO_EMIT
8947
+ },
8948
+ 422
8949
+ );
8950
+ }
8951
+ const parsedOps = [];
8952
+ for (const f of files) {
8953
+ const parsed = readOpIr(f.path, (p) => readFileSync14(p, "utf8"));
8954
+ if (!parsed.ok) return c.json(parsed.refusal, 422);
8955
+ parsedOps.push(parsed.ir);
8956
+ }
8957
+ const declaredIr = opsToIr(parsedOps);
8958
+ const runState = runner.runState;
8959
+ const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
8960
+ const join21 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
8961
+ const opsIr = join21 ? paintPlayhead(declaredIr, join21, runState.gate) : declaredIr;
8962
+ const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
8963
+ const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
8964
+ return c.json({
8965
+ ir: opsIr,
8966
+ svg: svg2,
8967
+ meta: {
8968
+ projectDir: cfg.projectDir,
8969
+ env: cfg.env ?? null,
8970
+ tier: null,
8971
+ target: null,
8972
+ mode: "ops",
8973
+ ops: parsedOps.length,
8974
+ note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join21)} ${operatorNote(operator)}`,
8975
+ // The operator strip's own model: the ConvergeOps this project
8976
+ // declares, plus whatever the last status read said. `declared`
8977
+ // without a `strip` is the honest starting state — a loop exists and
8978
+ // behold hasn't asked about it yet.
8979
+ operator,
8980
+ // The playhead's own model, so the SPA renders the pending card and
8981
+ // the run legend from the same answer that painted the graph.
8982
+ run: runState,
8983
+ // #234's pending-approval card, whole: the gate, when it started
8984
+ // waiting, the step it holds back, and the delegated write its
8985
+ // approve button performs. Present only when `gateState` NAMED a
8986
+ // pending gate — never inferred from a stalled track.
8987
+ gate: runState.gate && runState.op ? pendingGateCard(runState.op, runState.gate, opsIr) : null
8988
+ }
8989
+ });
8990
+ }
6989
8991
  const components = url.searchParams.get("components") === "1";
6990
8992
  const logical = url.searchParams.get("logical") === "1";
6991
8993
  const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
@@ -6996,6 +8998,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6996
8998
  ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
6997
8999
  ir = addValueMatchEdges(ir);
6998
9000
  ir = addK8sDeclaredEdges(ir);
9001
+ ir = markOperatorHome(ir);
6999
9002
  const estateContext = await boundK8sContext(metaEnv ?? void 0);
7000
9003
  ir = addClusterAnchorEdges(ir, estateContext);
7001
9004
  if (logical) {
@@ -7040,6 +9043,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7040
9043
  if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
7041
9044
  ir = addValueMatchEdges(ir);
7042
9045
  ir = addK8sDeclaredEdges(ir);
9046
+ ir = markOperatorHome(ir);
7043
9047
  ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
7044
9048
  }
7045
9049
  let srcCompositeEdgesAttached;
@@ -7053,7 +9057,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7053
9057
  srcCompositeEdgesAttached = 0;
7054
9058
  }
7055
9059
  }
7056
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join16(cfg.projectDir, "chant.config.ts"))) {
9060
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join18(cfg.projectDir, "chant.config.ts"))) {
7057
9061
  return c.json(noProjectError(cfg.projectDir), 404);
7058
9062
  }
7059
9063
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
@@ -7171,6 +9175,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7171
9175
  if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
7172
9176
  ir2 = addValueMatchEdges(ir2);
7173
9177
  ir2 = addK8sDeclaredEdges(ir2);
9178
+ ir2 = markOperatorHome(ir2);
7174
9179
  const boundContext2 = await boundK8sContext(env);
7175
9180
  ir2 = addClusterAnchorEdges(ir2, boundContext2);
7176
9181
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
@@ -7216,6 +9221,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7216
9221
  const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7217
9222
  applyHelmArtifacts(ir, observed);
7218
9223
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
9224
+ applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
7219
9225
  }
7220
9226
  ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
7221
9227
  if (logical) {
@@ -7229,6 +9235,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7229
9235
  if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
7230
9236
  ir = addValueMatchEdges(ir);
7231
9237
  ir = addK8sDeclaredEdges(ir);
9238
+ ir = markOperatorHome(ir);
7232
9239
  ir = addClusterAnchorEdges(ir, boundContext);
7233
9240
  let compositeEdgesAttached;
7234
9241
  if (query.detail === 1) {
@@ -7264,6 +9271,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7264
9271
  const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7265
9272
  applyHelmArtifacts(result.ir, observed);
7266
9273
  synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
9274
+ applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
7267
9275
  }
7268
9276
  }
7269
9277
  if (env) {
@@ -7391,9 +9399,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7391
9399
  }
7392
9400
  return c.json({ started: true, component, env });
7393
9401
  });
7394
- const rel = relative3(process.cwd(), webRoot) || ".";
9402
+ const rel = relative4(process.cwd(), webRoot) || ".";
7395
9403
  app.use("/*", serveStatic({ root: rel }));
7396
- app.get("/", serveStatic({ path: join16(rel, "index.html") }));
9404
+ app.get("/", serveStatic({ path: join18(rel, "index.html") }));
7397
9405
  return app;
7398
9406
  }
7399
9407
  async function bootLocalEmulators(dir, who) {
@@ -7422,7 +9430,7 @@ async function bootLocalEmulators(dir, who) {
7422
9430
  async function startServer(cfg) {
7423
9431
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
7424
9432
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
7425
- if (existsSync13(join16(dir, "chant.config.ts"))) addRecent(dir);
9433
+ if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
7426
9434
  }
7427
9435
  const broadcaster = new Broadcaster();
7428
9436
  const frames = new FrameBuffer();
@@ -7434,17 +9442,22 @@ async function startServer(cfg) {
7434
9442
  const app = createApp(cfg, broadcaster, frames, runner);
7435
9443
  const autoSync = cfg.autoSync ?? "off";
7436
9444
  const capture2 = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
7437
- const onEstateChange = () => {
7438
- broadcaster.emit("changed");
9445
+ const onEstateChange = (memberDir) => {
9446
+ broadcaster.emit("changed", memberDir ?? "");
7439
9447
  void capture2();
7440
9448
  };
7441
- const onPollDrift = (movedLexicons) => {
7442
- onEstateChange();
9449
+ const onMemberSourceChange = (memberDir) => {
9450
+ invalidateMember(memberDir);
9451
+ onEstateChange(memberDir);
9452
+ };
9453
+ const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
9454
+ const onPollDrift = (dir, movedLexicons) => {
9455
+ onEstateChange(dir);
7443
9456
  if (autoSync === "off") return;
7444
- void routeAutoSync(movedLexicons);
9457
+ void routeAutoSync(dir, movedLexicons);
7445
9458
  };
7446
- const routeAutoSync = async (movedLexicons) => {
7447
- const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(cfg.projectDir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
9459
+ const routeAutoSync = async (dir, movedLexicons) => {
9460
+ const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(dir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
7448
9461
  const { picks, declined } = pickAutoSyncOps(
7449
9462
  autoSync,
7450
9463
  discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]),
@@ -7453,31 +9466,37 @@ async function startServer(cfg) {
7453
9466
  suspended
7454
9467
  );
7455
9468
  for (const d of declined) {
7456
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) declined ${d.lexicon}: ${d.reason}`);
9469
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} declined ${d.lexicon}: ${d.reason}`);
7457
9470
  }
7458
9471
  for (const { op, lexicons } of picks) {
7459
9472
  const scope = lexicons.join("+");
7460
- if (runner.trigger(op.name, op.env, op.dir)) {
7461
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name}`);
9473
+ if (runner.trigger(op.name, op.env, op.dir, Boolean(op.gate))) {
9474
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} ${scope} \u2192 ${op.name}`);
7462
9475
  } else {
7463
- broadcaster.emit("op", `\u27F3 auto-sync (${autoSync}) ${scope} \u2192 ${op.name} waiting \u2014 ${runner.running} is running`);
9476
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} ${scope} \u2192 ${op.name} waiting \u2014 ${runner.running} is running`);
7464
9477
  }
7465
9478
  }
7466
9479
  };
7467
9480
  const carve = !!cfg.carveReport;
7468
9481
  let stopWatch = carve ? () => {
7469
- } : watchSource(cfg.projectDir, onEstateChange);
9482
+ } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onMemberSourceChange);
7470
9483
  let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
7471
9484
  intervalMs: cfg.pollSecs * 1e3,
7472
- query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }),
9485
+ // Per-member queries, swept sequentially inside the poll (#297): a
9486
+ // member read can take seconds (#295), so an estate-wide tick must
9487
+ // stretch, not stampede N describes at once.
9488
+ members: (cfg.projectDirs ?? [cfg.projectDir]).map((dir) => ({
9489
+ dir,
9490
+ query: () => graphIr(dir, { live: true, overlay: true, env: cfg.env })
9491
+ })),
7473
9492
  onChange: onPollDrift,
7474
- onError: (err) => process.stderr.write(`poll: ${err instanceof Error ? err.message : String(err)}
9493
+ onError: (dir, err) => process.stderr.write(`poll${memberTag(dir)}: ${err instanceof Error ? err.message : String(err)}
7475
9494
  `)
7476
9495
  }) : () => {
7477
9496
  };
7478
9497
  cfg.onProjectSwitch = (dir) => {
7479
9498
  stopWatch();
7480
- stopWatch = watchSource(dir, onEstateChange);
9499
+ stopWatch = watchSources(cfg.projectDirs ?? [dir], onMemberSourceChange);
7481
9500
  stopPoll();
7482
9501
  stopPoll = () => {
7483
9502
  };
@@ -7550,12 +9569,12 @@ async function startServer(cfg) {
7550
9569
  }
7551
9570
 
7552
9571
  // src/export.ts
7553
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
7554
- import { join as join17, dirname as dirname5, basename } from "node:path";
9572
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "node:fs";
9573
+ import { join as join19, dirname as dirname6, basename as basename2 } from "node:path";
7555
9574
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7556
- var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
9575
+ var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
7557
9576
  function canonicalKey(path, params) {
7558
- const flat = params.get("components") === "1" || params.get("logical") === "1";
9577
+ const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
7559
9578
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
7560
9579
  return q ? `${path}?${q}` : path;
7561
9580
  }
@@ -7569,6 +9588,7 @@ function captureKeys(axes) {
7569
9588
  add("/api/project", {});
7570
9589
  add("/api/substrates", {});
7571
9590
  add("/api/ops", {});
9591
+ if (axes.ops) add("/api/graph", { ops: "1" });
7572
9592
  const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
7573
9593
  const envs = ["", ...axes.environments];
7574
9594
  for (const env of envs) {
@@ -7599,18 +9619,18 @@ function captureKeys(axes) {
7599
9619
  return [...keys];
7600
9620
  }
7601
9621
  function webDir() {
7602
- return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
9622
+ return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
7603
9623
  }
7604
9624
  function workerName(project, override) {
7605
- const raw = override ?? `behold-${basename(project)}`;
9625
+ const raw = override ?? `behold-${basename2(project)}`;
7606
9626
  const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7607
9627
  return name || "behold-export";
7608
9628
  }
7609
9629
  async function runExport(cfg, outDir, opts = {}) {
7610
9630
  const app = createApp({ ...cfg, layoutWrites: false });
7611
9631
  const proj = await (await app.request("/api/project")).json();
7612
- const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
7613
- const snapDir = join17(outDir, "snapshots");
9632
+ const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
9633
+ const snapDir = join19(outDir, "snapshots");
7614
9634
  mkdirSync4(snapDir, { recursive: true });
7615
9635
  const keyToFile = {};
7616
9636
  let ok = 0;
@@ -7619,7 +9639,7 @@ async function runExport(cfg, outDir, opts = {}) {
7619
9639
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
7620
9640
  const body = await res.text();
7621
9641
  const file = slug2(key);
7622
- writeFileSync4(join17(snapDir, file), body);
9642
+ writeFileSync4(join19(snapDir, file), body);
7623
9643
  keyToFile[key] = `snapshots/${file}`;
7624
9644
  if (res.ok) ok++;
7625
9645
  else failed++;
@@ -7631,21 +9651,21 @@ async function runExport(cfg, outDir, opts = {}) {
7631
9651
  axes,
7632
9652
  keyToFile
7633
9653
  };
7634
- writeFileSync4(join17(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7635
- const html = readFileSync14(join17(webDir(), "index.html"), "utf8").replace(
9654
+ writeFileSync4(join19(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
9655
+ const html = readFileSync15(join19(webDir(), "index.html"), "utf8").replace(
7636
9656
  /<\/head>/i,
7637
9657
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
7638
9658
  </head>`
7639
9659
  );
7640
- writeFileSync4(join17(outDir, "index.html"), html);
7641
- for (const f of readdirSync5(webDir())) {
9660
+ writeFileSync4(join19(outDir, "index.html"), html);
9661
+ for (const f of readdirSync7(webDir())) {
7642
9662
  if (f === "index.html") continue;
7643
- copyFileSync(join17(webDir(), f), join17(outDir, f));
9663
+ copyFileSync(join19(webDir(), f), join19(outDir, f));
7644
9664
  }
7645
- writeFileSync4(join17(outDir, "README.md"), BUNDLE_README);
9665
+ writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
7646
9666
  const name = workerName(cfg.projectDir, opts.name);
7647
9667
  writeFileSync4(
7648
- join17(outDir, "wrangler.jsonc"),
9668
+ join19(outDir, "wrangler.jsonc"),
7649
9669
  JSON.stringify(
7650
9670
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
7651
9671
  null,
@@ -7693,7 +9713,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
7693
9713
  `;
7694
9714
 
7695
9715
  // src/doctor.ts
7696
- import { relative as relative4, resolve as resolve6 } from "node:path";
9716
+ import { relative as relative5, resolve as resolve7 } from "node:path";
7697
9717
  var list2 = (xs) => xs.join(", ");
7698
9718
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
7699
9719
  function summarize(xs, max = 4) {
@@ -7701,7 +9721,7 @@ function summarize(xs, max = 4) {
7701
9721
  }
7702
9722
  function labelFor(root, target, estate) {
7703
9723
  if (!estate) return "";
7704
- const rel = relative4(root, target);
9724
+ const rel = relative5(root, target);
7705
9725
  return `${rel || "."}: `;
7706
9726
  }
7707
9727
  function chantCheck(root, targets, estate) {
@@ -7714,7 +9734,7 @@ function chantCheck(root, targets, estate) {
7714
9734
  name: "chant",
7715
9735
  status: "fail",
7716
9736
  detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
7717
- fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative4(root, target) || target))}`
9737
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
7718
9738
  };
7719
9739
  }
7720
9740
  const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
@@ -7842,7 +9862,7 @@ function opsCheck(root, ops, chantSource, estate) {
7842
9862
  return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
7843
9863
  }
7844
9864
  async function diagnose(dir, probes = {}) {
7845
- const root = resolve6(dir);
9865
+ const root = resolve7(dir);
7846
9866
  const shape = detectProjectShape(root);
7847
9867
  const behold = beholdVersion();
7848
9868
  if (shape.kind === "none") {
@@ -7862,16 +9882,16 @@ async function diagnose(dir, probes = {}) {
7862
9882
  };
7863
9883
  }
7864
9884
  const estate = shape.kind === "estate";
7865
- const members = (shape.members ?? []).map((m) => resolve6(root, m));
9885
+ const members = (shape.members ?? []).map((m) => resolve7(root, m));
7866
9886
  const targets = estate ? members : [root];
7867
9887
  const primary = targets[0];
7868
9888
  const projectCheck = estate ? {
7869
9889
  name: "project",
7870
9890
  status: "pass",
7871
9891
  detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
7872
- members.map((m) => relative4(root, m))
9892
+ members.map((m) => relative5(root, m))
7873
9893
  )}`
7874
- } : { name: "project", status: "pass", detail: `chant project (${relative4(root, shape.configFile)})` };
9894
+ } : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
7875
9895
  const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
7876
9896
  const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
7877
9897
  const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
@@ -8096,7 +10116,7 @@ ${USAGE}`);
8096
10116
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
8097
10117
  process.exit(2);
8098
10118
  }
8099
- const dirs = projectDirs.map((d) => resolve7(d));
10119
+ const dirs = projectDirs.map((d) => resolve8(d));
8100
10120
  for (const d of dirs) warnIfNotChantProject(d);
8101
10121
  await startServer({
8102
10122
  projectDir: dirs[0],
@@ -8115,7 +10135,7 @@ function warnIfNotChantProject(dir) {
8115
10135
  if (shape.kind === "estate") {
8116
10136
  process.stderr.write(
8117
10137
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
8118
- Serve its members composed: behold serve ${shape.members.map((m) => join18(dir, m)).join(" ")}
10138
+ Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
8119
10139
  `
8120
10140
  );
8121
10141
  return;
@@ -8150,8 +10170,8 @@ async function runCarve(rest) {
8150
10170
  );
8151
10171
  process.exit(2);
8152
10172
  }
8153
- const reportPath = resolve7(fileArg);
8154
- const parsed = readCarveReport(reportPath, (p) => readFileSync15(p, "utf8"));
10173
+ const reportPath = resolve8(fileArg);
10174
+ const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
8155
10175
  if (!parsed.ok) {
8156
10176
  process.stderr.write(`behold carve: ${parsed.refusal.error}
8157
10177
  ${parsed.refusal.remedy}
@@ -8162,7 +10182,7 @@ async function runCarve(rest) {
8162
10182
  `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
8163
10183
  `
8164
10184
  );
8165
- await startServer({ projectDir: dirname6(reportPath), carveReport: reportPath, port });
10185
+ await startServer({ projectDir: dirname7(reportPath), carveReport: reportPath, port });
8166
10186
  }
8167
10187
  async function runDoctor(rest) {
8168
10188
  let json = false;
@@ -8178,8 +10198,8 @@ async function runDoctor(rest) {
8178
10198
  }
8179
10199
  }
8180
10200
  const dir = dirArg ?? ".";
8181
- if (!existsSync14(resolve7(dir))) {
8182
- process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
10201
+ if (!existsSync14(resolve8(dir))) {
10202
+ process.stderr.write(`behold doctor: no such directory: ${resolve8(dir)}
8183
10203
  `);
8184
10204
  process.exit(2);
8185
10205
  }
@@ -8188,7 +10208,7 @@ async function runDoctor(rest) {
8188
10208
  if (!report.ok) process.exitCode = 1;
8189
10209
  }
8190
10210
  async function runDemo(rest) {
8191
- const pkgRoot2 = join18(dirname6(fileURLToPath5(import.meta.url)), "..");
10211
+ const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
8192
10212
  const registry = loadDemoRegistry(pkgRoot2);
8193
10213
  let port = 4600;
8194
10214
  let live = false;
@@ -8242,7 +10262,7 @@ async function runDemo(rest) {
8242
10262
  `);
8243
10263
  process.exit(2);
8244
10264
  }
8245
- const target = dirArg ? resolve7(dirArg) : demoTargetDir(entry);
10265
+ const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
8246
10266
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
8247
10267
  if (!loaded.ok) {
8248
10268
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -8273,7 +10293,7 @@ function spawnStep(cmd, args, cwd) {
8273
10293
  });
8274
10294
  }
8275
10295
  async function serveCarveDemo(target, carve, port, live = false) {
8276
- const at = (rel) => resolve7(target, rel);
10296
+ const at = (rel) => resolve8(target, rel);
8277
10297
  const project = at(carve.project);
8278
10298
  const from = at(carve.from);
8279
10299
  const state = carve.state ? at(carve.state) : void 0;
@@ -8313,7 +10333,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8313
10333
  };
8314
10334
  process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
8315
10335
  }
8316
- if (existsSync14(join18(project, "package.json")) && !existsSync14(join18(project, "node_modules"))) {
10336
+ if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
8317
10337
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
8318
10338
  `);
8319
10339
  const code = await spawnStep("npm", ["install"], project);
@@ -8324,7 +10344,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8324
10344
  }
8325
10345
  }
8326
10346
  let degraded;
8327
- if (!existsSync14(join18(target, "node_modules", "@cdktf", "hcl2json"))) {
10347
+ if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
8328
10348
  process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
8329
10349
  const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
8330
10350
  if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
@@ -8393,7 +10413,7 @@ async function runPreview(rest) {
8393
10413
  process.stderr.write("behold preview: --port must be a number\n");
8394
10414
  process.exit(2);
8395
10415
  }
8396
- const projectDir = resolve7(dirArg ?? process.cwd());
10416
+ const projectDir = resolve8(dirArg ?? process.cwd());
8397
10417
  if (!existsSync14(projectDir)) {
8398
10418
  process.stderr.write(`behold preview: project not found at ${projectDir}
8399
10419
  `);
@@ -8414,21 +10434,21 @@ async function runPreview(rest) {
8414
10434
  await startServer({ projectDir, port, env: "local", previewMode: true });
8415
10435
  }
8416
10436
  async function runExportCmd(rest) {
8417
- let outDir = resolve7("behold-export");
10437
+ let outDir = resolve8("behold-export");
8418
10438
  let env;
8419
10439
  let name;
8420
10440
  let dirArg;
8421
10441
  let emulator = false;
8422
10442
  for (let i = 0; i < rest.length; i++) {
8423
10443
  const a = rest[i];
8424
- if (a === "--out") outDir = resolve7(rest[++i]);
10444
+ if (a === "--out") outDir = resolve8(rest[++i]);
8425
10445
  else if (a === "--env") env = rest[++i];
8426
10446
  else if (a === "--name") name = rest[++i];
8427
10447
  else if (a === "--emulator") emulator = true;
8428
10448
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
8429
10449
  else if (!a.startsWith("-")) dirArg = a;
8430
10450
  }
8431
- const projectDir = resolve7(dirArg ?? process.cwd());
10451
+ const projectDir = resolve8(dirArg ?? process.cwd());
8432
10452
  if (emulator) {
8433
10453
  injectEmulatorEnv(env);
8434
10454
  env ??= "local";