@intentius/behold 0.11.0 → 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 { basename, dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1123
+ import { existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
1124
+ import { basename, dirname as dirname5, join as join18, relative as relative4, resolve as resolve6, sep as sep4 } from "node:path";
1125
1125
 
1126
1126
  // src/recents.ts
1127
1127
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -1310,6 +1310,8 @@ function detectProjectShape(projectDir) {
1310
1310
  }
1311
1311
 
1312
1312
  // node_modules/@intentius/chant/src/yaml.ts
1313
+ var KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
1314
+ var ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
1313
1315
  function parseYAML(content) {
1314
1316
  try {
1315
1317
  return JSON.parse(content);
@@ -1385,7 +1387,7 @@ function parseYAMLLines(lines, startIndex, baseIndent) {
1385
1387
  const indent = line.search(/\S/);
1386
1388
  if (indent < baseIndent) break;
1387
1389
  if (indent > baseIndent && startIndex > 0) break;
1388
- const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
1390
+ const keyMatch = line.match(KEY_LINE);
1389
1391
  if (keyMatch) {
1390
1392
  const key = keyMatch[2].trim();
1391
1393
  const inlineValue = keyMatch[3].trim();
@@ -1518,7 +1520,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1518
1520
  if (itemMatch && indent === baseIndent) {
1519
1521
  const itemValue = itemMatch[2].trim();
1520
1522
  const isQuotedScalar = itemValue.startsWith('"') && itemValue.endsWith('"') || itemValue.startsWith("'") && itemValue.endsWith("'");
1521
- const kvMatch = !isQuotedScalar && itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
1523
+ const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
1522
1524
  if (kvMatch) {
1523
1525
  const obj = {};
1524
1526
  obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
@@ -1534,7 +1536,7 @@ function parseYAMLArray(lines, startIndex, baseIndent) {
1534
1536
  const ni = nextLine.search(/\S/);
1535
1537
  if (ni < nextIndent) break;
1536
1538
  if (ni > nextIndent) break;
1537
- const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
1539
+ const nextKV = nextLine.match(KEY_LINE);
1538
1540
  if (nextKV) {
1539
1541
  const nextVal = nextKV[3].trim();
1540
1542
  obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
@@ -1572,13 +1574,68 @@ function parseScalar(value) {
1572
1574
  if (value === "" || value === "~" || value === "null") return null;
1573
1575
  if (value === "true" || value === "yes") return true;
1574
1576
  if (value === "false" || value === "no") return false;
1575
- if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
1576
- return value.slice(1, -1);
1577
+ if (value.length >= 2) {
1578
+ if (value.startsWith('"') && value.endsWith('"')) {
1579
+ const unescaped = unescapeDoubleQuoted(value.slice(1, -1));
1580
+ return unescaped ?? value.slice(1, -1);
1581
+ }
1582
+ if (value.startsWith("'") && value.endsWith("'")) {
1583
+ return value.slice(1, -1).replace(/''/g, "'");
1584
+ }
1577
1585
  }
1578
1586
  const num = Number(value);
1579
1587
  if (!isNaN(num) && value !== "") return num;
1580
1588
  return value;
1581
1589
  }
1590
+ var DQ_ESCAPES = {
1591
+ "0": "\0",
1592
+ a: "\x07",
1593
+ b: "\b",
1594
+ t: " ",
1595
+ " ": " ",
1596
+ n: "\n",
1597
+ v: "\v",
1598
+ f: "\f",
1599
+ r: "\r",
1600
+ e: "\x1B",
1601
+ " ": " ",
1602
+ '"': '"',
1603
+ "/": "/",
1604
+ "\\": "\\",
1605
+ N: "\x85",
1606
+ _: "\xA0",
1607
+ L: "\u2028",
1608
+ P: "\u2029"
1609
+ };
1610
+ function unescapeDoubleQuoted(body) {
1611
+ let out = "";
1612
+ for (let i = 0; i < body.length; i++) {
1613
+ const ch = body[i];
1614
+ if (ch === '"') return null;
1615
+ if (ch !== "\\") {
1616
+ out += ch;
1617
+ continue;
1618
+ }
1619
+ const esc = body[++i];
1620
+ if (esc === void 0) return null;
1621
+ const width = esc === "x" ? 2 : esc === "u" ? 4 : esc === "U" ? 8 : 0;
1622
+ if (width > 0) {
1623
+ const hex = body.slice(i + 1, i + 1 + width);
1624
+ if (hex.length < width || !/^[0-9a-fA-F]+$/.test(hex)) return null;
1625
+ out += String.fromCodePoint(parseInt(hex, 16));
1626
+ i += width;
1627
+ continue;
1628
+ }
1629
+ if (esc === "\n") {
1630
+ while (body[i + 1] === " " || body[i + 1] === " ") i++;
1631
+ continue;
1632
+ }
1633
+ const mapped = DQ_ESCAPES[esc];
1634
+ if (mapped === void 0) return null;
1635
+ out += mapped;
1636
+ }
1637
+ return out;
1638
+ }
1582
1639
 
1583
1640
  // src/chant.ts
1584
1641
  function envOverridesFor(opts) {
@@ -1836,6 +1893,25 @@ function lifecycleDiffArgs(env) {
1836
1893
  function lifecycleDiffLive(projectDir, env, opts = {}) {
1837
1894
  return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
1838
1895
  }
1896
+ function helmRendersArgs() {
1897
+ return ["helm", "renders", "--json"];
1898
+ }
1899
+ function helmRenders(projectDir, opts = {}) {
1900
+ return runChantJson(helmRendersArgs(), projectDir, envOverridesFor(opts));
1901
+ }
1902
+ function helmRenderDiffArgs(contentDigest, env) {
1903
+ return ["helm", "diff", contentDigest, env, "--live", "--json"];
1904
+ }
1905
+ async function helmRenderDiffLive(projectDir, contentDigest, env, opts = {}) {
1906
+ const args = helmRenderDiffArgs(contentDigest, env);
1907
+ const { code, stdout } = await runChantRaw(args, projectDir, envOverridesFor(opts));
1908
+ if (code !== 0) return void 0;
1909
+ try {
1910
+ return JSON.parse(stdout);
1911
+ } catch {
1912
+ return void 0;
1913
+ }
1914
+ }
1839
1915
  function applyArgs(target, env) {
1840
1916
  return ["run", target, "--components", "--env", env, "--progress-json"];
1841
1917
  }
@@ -2042,19 +2118,19 @@ function finishPipelineProgress(state, exitCode) {
2042
2118
  }
2043
2119
 
2044
2120
  // src/gh-run.ts
2045
- var defaultGhExec = (args) => new Promise((resolve8) => {
2121
+ var defaultGhExec = (args) => new Promise((resolve9) => {
2046
2122
  let out = "";
2047
2123
  let proc;
2048
2124
  try {
2049
2125
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
2050
2126
  } catch {
2051
- resolve8({ code: 127, out: "" });
2127
+ resolve9({ code: 127, out: "" });
2052
2128
  return;
2053
2129
  }
2054
2130
  proc.stdout.on("data", (d) => out += d);
2055
2131
  proc.stderr.on("data", (d) => out += d);
2056
- proc.on("error", () => resolve8({ code: 127, out }));
2057
- proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
2132
+ proc.on("error", () => resolve9({ code: 127, out }));
2133
+ proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
2058
2134
  });
2059
2135
  async function ghReady(exec = defaultGhExec) {
2060
2136
  const { code, out } = await exec(["auth", "status"]);
@@ -2598,181 +2674,602 @@ function applyHelmArtifacts(ir, observed) {
2598
2674
  return { charts, installed };
2599
2675
  }
2600
2676
 
2601
- // src/cluster-anchor.ts
2602
- var MANAGED_CLUSTER_KINDS = /* @__PURE__ */ new Set([
2603
- "AWS::EKS::Cluster",
2604
- "GCP::Container::Cluster",
2605
- "Microsoft.ContainerService/managedClusters",
2606
- "K3d::Cluster"
2607
- ]);
2608
- var ANCHOR_VIA = "runs-on";
2609
- function metadata(node) {
2610
- const m = node.attrs?.metadata;
2611
- return m && typeof m === "object" && !Array.isArray(m) ? m : void 0;
2612
- }
2613
- function metaString(node, key) {
2614
- const v = metadata(node)?.[key];
2615
- return typeof v === "string" && v.length > 0 ? v : void 0;
2616
- }
2617
- function isK8s(node) {
2618
- return node.lexicon === "k8s";
2619
- }
2620
- function isNamespace(node) {
2621
- return node.kind === "K8s::Core::Namespace";
2622
- }
2623
- function managedClusterName(node) {
2624
- const metaName = metaString(node, "name");
2625
- if (metaName) return metaName;
2626
- for (const key of ["name", "clusterName"]) {
2627
- const v = node.attrs?.[key];
2628
- if (typeof v === "string" && v.length > 0) return v;
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;
2629
2716
  }
2630
- return node.id;
2717
+ return any ? h.digest("hex") : void 0;
2631
2718
  }
2632
- function boundManagedCluster(nodes, boundContext) {
2633
- const clusters = nodes.filter((n) => MANAGED_CLUSTER_KINDS.has(n.kind));
2634
- if (clusters.length === 1) return clusters[0];
2635
- if (clusters.length === 0 || !boundContext) return void 0;
2636
- const bound = clusters.filter((n) => contextBindsCluster(boundContext, managedClusterName(n)));
2637
- return bound.length === 1 ? bound[0] : void 0;
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}`;
2638
2723
  }
2639
- function addClusterAnchorEdges(ir, boundContext) {
2640
- const nodes = ir.nodes;
2641
- const cluster = boundManagedCluster(ir.nodes, boundContext);
2642
- if (!cluster) return ir;
2643
- const k8sNodes = nodes.filter(isK8s);
2644
- if (k8sNodes.length === 0) return ir;
2645
- const namespaceByName = /* @__PURE__ */ new Map();
2646
- for (const n of k8sNodes) {
2647
- if (!isNamespace(n)) continue;
2648
- const name = metaString(n, "name");
2649
- if (name && !namespaceByName.has(name)) namespaceByName.set(name, n.id);
2650
- }
2651
- const existing = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`));
2652
- const add = (from, to) => {
2653
- if (from === to) return;
2654
- const key = `${from}\0${to}`;
2655
- if (existing.has(key)) return;
2656
- existing.add(key);
2657
- ir.edges.push({ from, to, kind: "ref", viaAttr: ANCHOR_VIA, inferred: true });
2658
- };
2659
- for (const n of k8sNodes) {
2660
- if (isNamespace(n)) {
2661
- add(cluster.id, n.id);
2662
- continue;
2663
- }
2664
- const ns = metaString(n, "namespace");
2665
- const nsNode = ns ? namespaceByName.get(ns) : void 0;
2666
- add(nsNode ?? cluster.id, n.id);
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;
2667
2731
  }
2668
- return ir;
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);
2669
2757
  }
2670
2758
 
2671
- // src/logical-azure.ts
2672
- var AZURE_HEADLINE_KINDS = /* @__PURE__ */ new Set([
2673
- // compute / workloads
2674
- "Microsoft.Compute/virtualMachines",
2675
- "Microsoft.Compute/virtualMachineScaleSets",
2676
- "Microsoft.ContainerService/managedClusters",
2677
- "Microsoft.ContainerInstance/containerGroups",
2678
- "Microsoft.Web/sites",
2679
- // network edge
2680
- "Microsoft.Network/publicIPAddresses",
2681
- "Microsoft.Network/loadBalancers",
2682
- "Microsoft.Network/applicationGateways",
2683
- "Microsoft.Network/natGateways",
2684
- "Microsoft.Network/dnsZones",
2685
- // data stores
2686
- "Microsoft.Storage/storageAccounts",
2687
- "Microsoft.Sql/servers",
2688
- "Microsoft.DocumentDB/databaseAccounts",
2689
- "Microsoft.Cache/redis",
2690
- "Microsoft.DBforPostgreSQL/flexibleServers",
2691
- // registry / identity edge
2692
- "Microsoft.ContainerRegistry/registries"
2693
- ]);
2694
- var AZURE_FABRIC_KINDS = /* @__PURE__ */ new Set([
2695
- "Microsoft.Network/virtualNetworks",
2696
- "Microsoft.Network/virtualNetworks_subnets",
2697
- "Microsoft.Network/networkSecurityGroups",
2698
- "Microsoft.Network/routeTables",
2699
- "Microsoft.Network/networkInterfaces",
2700
- // identity/authorization hubs — a role assignment or managed identity is
2701
- // referenced by half the estate, so contracting through one invents edges
2702
- // between unrelated resources.
2703
- "Microsoft.Authorization/roleAssignments",
2704
- "Microsoft.ManagedIdentity/userAssignedIdentities",
2705
- "Microsoft.KeyVault/vaults"
2706
- ]);
2707
- var VNET_KIND = "Microsoft.Network/virtualNetworks";
2708
- var SUBNET_KIND = "Microsoft.Network/virtualNetworks_subnets";
2709
- var NSG_KIND = "Microsoft.Network/networkSecurityGroups";
2710
- var RESOURCE_ID_RE = /resourceId\(\s*([^)]*)\)/g;
2711
- var QUOTED_RE = /'([^']*)'/g;
2712
- function resourceIdNames(value) {
2713
- if (typeof value !== "string" || !value.includes("resourceId(")) return [];
2714
- const out = [];
2715
- for (const call of value.matchAll(RESOURCE_ID_RE)) {
2716
- const args = [...call[1].matchAll(QUOTED_RE)].map((m) => m[1]);
2717
- if (args.length >= 2) out.push(args[args.length - 1]);
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;
2718
2764
  }
2719
- return out;
2720
- }
2721
- function collectAzureRefs(value, out) {
2722
- if (value === null || typeof value !== "object") {
2723
- for (const name of resourceIdNames(value)) out.add(name);
2724
- return;
2765
+ if (zoom === "logical") {
2766
+ return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
2725
2767
  }
2726
- const ref = value.$ref;
2727
- if (typeof ref === "string") out.add(ref.split(".")[0]);
2728
- for (const v of Object.values(value)) collectAzureRefs(v, out);
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;
2729
2776
  }
2730
- function strAttr(node, key) {
2731
- const v = node.attrs?.[key];
2732
- return typeof v === "string" ? v : void 0;
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)`;
2733
2780
  }
2734
- function resourceName(node) {
2735
- return strAttr(node, "name");
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)";
2786
+ }
2787
+ return "no edges \u2014 nothing in this estate references anything else";
2736
2788
  }
2737
- function subnetVnetName(node) {
2738
- const name = resourceName(node);
2739
- if (!name || !name.includes("/")) return void 0;
2740
- return name.split("/")[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`;
2741
2804
  }
2742
- function subnetShortName(node) {
2743
- const name = resourceName(node);
2744
- return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
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`;
2745
2809
  }
2746
- function vnetCidr(node) {
2747
- const space = node.attrs?.addressSpace;
2748
- const prefixes = space?.addressPrefixes;
2749
- return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
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;
2750
2814
  }
2751
- function projectAzureLogical(ir, env) {
2752
- const azure = ir.nodes.filter((n) => n.lexicon === "azure");
2753
- if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
2754
- const componentOf = componentNamer(azure);
2755
- const byId = new Map(azure.map((n) => [n.id, n]));
2756
- const byName = /* @__PURE__ */ new Map();
2757
- for (const n of azure) {
2758
- const name = resourceName(n);
2759
- if (name && !byName.has(name)) byName.set(name, n);
2760
- }
2761
- const vnets = azure.filter((n) => n.kind === VNET_KIND);
2762
- const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
2763
- const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
2764
- const refOut = /* @__PURE__ */ new Map();
2765
- for (const n of azure) {
2766
- const names = /* @__PURE__ */ new Set();
2767
- collectAzureRefs(n.attrs, names);
2768
- const targets = /* @__PURE__ */ new Set();
2769
- for (const name of names) {
2770
- const target = byName.get(name) ?? byId.get(name);
2771
- if (target && target.id !== n.id) targets.add(target.id);
2772
- }
2773
- if (targets.size) refOut.set(n.id, targets);
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);
2774
2826
  }
2775
- for (const e of ir.edges) {
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);
2848
+ }
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}`;
2870
+ }
2871
+ return ir;
2872
+ }
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) {
2884
+ const out = [];
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 });
2891
+ }
2892
+ return out;
2893
+ }
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) {
3219
+ if (value === null || typeof value !== "object") {
3220
+ for (const name of resourceIdNames(value)) out.add(name);
3221
+ return;
3222
+ }
3223
+ const ref = value.$ref;
3224
+ if (typeof ref === "string") out.add(ref.split(".")[0]);
3225
+ for (const v of Object.values(value)) collectAzureRefs(v, out);
3226
+ }
3227
+ function strAttr(node, key) {
3228
+ const v = node.attrs?.[key];
3229
+ return typeof v === "string" ? v : void 0;
3230
+ }
3231
+ function resourceName(node) {
3232
+ return strAttr(node, "name");
3233
+ }
3234
+ function subnetVnetName(node) {
3235
+ const name = resourceName(node);
3236
+ if (!name || !name.includes("/")) return void 0;
3237
+ return name.split("/")[0];
3238
+ }
3239
+ function subnetShortName(node) {
3240
+ const name = resourceName(node);
3241
+ return name?.includes("/") ? name.split("/").slice(1).join("/") : name;
3242
+ }
3243
+ function vnetCidr(node) {
3244
+ const space = node.attrs?.addressSpace;
3245
+ const prefixes = space?.addressPrefixes;
3246
+ return Array.isArray(prefixes) && typeof prefixes[0] === "string" ? prefixes[0] : void 0;
3247
+ }
3248
+ function projectAzureLogical(ir, env) {
3249
+ const azure = ir.nodes.filter((n) => n.lexicon === "azure");
3250
+ if (azure.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
3251
+ const componentOf = componentNamer(azure);
3252
+ const byId = new Map(azure.map((n) => [n.id, n]));
3253
+ const byName = /* @__PURE__ */ new Map();
3254
+ for (const n of azure) {
3255
+ const name = resourceName(n);
3256
+ if (name && !byName.has(name)) byName.set(name, n);
3257
+ }
3258
+ const vnets = azure.filter((n) => n.kind === VNET_KIND);
3259
+ const subnets = azure.filter((n) => n.kind === SUBNET_KIND);
3260
+ const vnetByName = new Map(vnets.map((v) => [resourceName(v) ?? v.id, v]));
3261
+ const refOut = /* @__PURE__ */ new Map();
3262
+ for (const n of azure) {
3263
+ const names = /* @__PURE__ */ new Set();
3264
+ collectAzureRefs(n.attrs, names);
3265
+ const targets = /* @__PURE__ */ new Set();
3266
+ for (const name of names) {
3267
+ const target = byName.get(name) ?? byId.get(name);
3268
+ if (target && target.id !== n.id) targets.add(target.id);
3269
+ }
3270
+ if (targets.size) refOut.set(n.id, targets);
3271
+ }
3272
+ for (const e of ir.edges) {
2776
3273
  if (!byId.has(e.from) || !byId.has(e.to)) continue;
2777
3274
  (refOut.get(e.from) ?? refOut.set(e.from, /* @__PURE__ */ new Set()).get(e.from)).add(e.to);
2778
3275
  }
@@ -3088,7 +3585,7 @@ function projectK8sLogical(ir, env, boundContext) {
3088
3585
  function releaseBoxTitle(chartName) {
3089
3586
  return `release ${chartName}`;
3090
3587
  }
3091
- function chartNameOf2(node) {
3588
+ function chartNameOf3(node) {
3092
3589
  const name = node.attrs?.name;
3093
3590
  return typeof name === "string" && name.length > 0 ? name : node.id;
3094
3591
  }
@@ -3109,7 +3606,7 @@ function projectHelmLogical(ir) {
3109
3606
  };
3110
3607
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
3111
3608
  for (const chart of charts) {
3112
- const box = releaseBoxTitle(chartNameOf2(chart));
3609
+ const box = releaseBoxTitle(chartNameOf3(chart));
3113
3610
  child(box, chart.id);
3114
3611
  const chartDir = dirOf(chart);
3115
3612
  if (chartDir === "") continue;
@@ -3126,7 +3623,7 @@ function projectHelmLogical(ir) {
3126
3623
 
3127
3624
  // src/logical-kustomize.ts
3128
3625
  import { existsSync as existsSync5 } from "node:fs";
3129
- import { join as join6 } from "node:path";
3626
+ import { join as join8 } from "node:path";
3130
3627
  function overlayBoxTitle(name) {
3131
3628
  return `overlay ${name}`;
3132
3629
  }
@@ -3146,7 +3643,7 @@ function kustomizationRoot(bases, dir, exists) {
3146
3643
  let current = dir;
3147
3644
  for (let i = 0; i < 4 && current; i++) {
3148
3645
  for (const base of bases) {
3149
- 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"))) {
3150
3647
  return current;
3151
3648
  }
3152
3649
  }
@@ -3722,83 +4219,6 @@ function addCompositeDepsCounted(ir, dag) {
3722
4219
  return { ir: out, attached: out.edges.length - before };
3723
4220
  }
3724
4221
 
3725
- // src/zoom-notes.ts
3726
- function zoomNote(zoom, ir, compositeEdgesAttached) {
3727
- const empty = ir.nodes.length === 0;
3728
- if (zoom === "components") {
3729
- return empty ? "no components discovered \u2014 nothing declares one in this project" : void 0;
3730
- }
3731
- if (zoom === "logical") {
3732
- return empty ? "logical is an AWS projection \u2014 no AWS resources in this estate" : void 0;
3733
- }
3734
- if (zoom === "composites") {
3735
- return compositeEdgesAttached === 0 ? "no component ownership to join \u2014 showing the resource graph unchanged" : void 0;
3736
- }
3737
- if (zoom === "runtime") {
3738
- const children = ir.nodes.filter((n) => n.runtimeOwner).length;
3739
- return children === 0 ? "nothing below the declaration boundary \u2014 no owner-referenced children on this substrate" : void 0;
3740
- }
3741
- return void 0;
3742
- }
3743
- function logicalKept(before, after) {
3744
- if (after > 0 && after * 3 >= before) return void 0;
3745
- return after === 0 ? `logical projected nothing from ${before} resources \u2014 it is a cloud-topology lens, and this estate declares none of the kinds it nests (behold#74)` : `logical kept ${after} of ${before} resources \u2014 it is a cloud-topology lens, and the rest are kinds it does not nest (behold#74)`;
3746
- }
3747
- function edgelessNote(zoom, ir, detail) {
3748
- if (zoom === "components" || zoom === "logical") return void 0;
3749
- if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
3750
- if (detail !== void 0 && detail < 3) {
3751
- return "no edges at this detail \u2014 sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (\u2318K \u2192 attributes, or add &detail=3)";
3752
- }
3753
- return "no edges \u2014 nothing in this estate references anything else";
3754
- }
3755
- var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
3756
- "K8s::Core::Namespace",
3757
- "K8s::Rbac::ClusterRole",
3758
- "K8s::Rbac::ClusterRoleBinding",
3759
- "K8s::Storage::StorageClass",
3760
- "K8s::Apiextensions::CustomResourceDefinition"
3761
- ]);
3762
- function namespaceMismatchNote(nodes) {
3763
- const k8s = nodes.filter((n) => n?.lexicon === "k8s");
3764
- if (k8s.length === 0) return void 0;
3765
- const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
3766
- if (pending.length < 2) return void 0;
3767
- if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
3768
- if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
3769
- return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
3770
- }
3771
- function namespaceJoinNote(joined) {
3772
- if (joined.length === 0) return void 0;
3773
- const each = joined.map((j) => `${j.name} in "${j.namespace}"`).join(", ");
3774
- return `read ${each} \u2014 the namespace the estate's own Kustomization targetNamespace binds, not the app project's own declaration`;
3775
- }
3776
- function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore, detail) {
3777
- const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
3778
- const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n) => n !== void 0);
3779
- return notes.length ? notes.join(" \xB7 ") : void 0;
3780
- }
3781
- function tierMismatchNote(ir, tiers, currentTier) {
3782
- const values = tiers?.values ?? [];
3783
- if (values.length < 2) return void 0;
3784
- const byLexicon = /* @__PURE__ */ new Map();
3785
- for (const n of ir.nodes) {
3786
- const status = n.attrs?._status;
3787
- if (status !== "good" && status !== "accent") continue;
3788
- const lexicon = n.lexicon ?? "?";
3789
- const c = byLexicon.get(lexicon) ?? { good: 0, accent: 0 };
3790
- c[status === "good" ? "good" : "accent"]++;
3791
- byLexicon.set(lexicon, c);
3792
- }
3793
- for (const [lexicon, { good, accent }] of [...byLexicon].sort()) {
3794
- if (accent < 2 || accent * 2 < good + accent) continue;
3795
- const here = currentTier ? `tier "${currentTier}"` : "the default tier";
3796
- const others = values.filter((v) => v !== currentTier).join(", ");
3797
- return `${accent} of ${good + accent} ${lexicon} resources read "declared, not deployed" at ${here} \u2014 if the estate runs another declared tier (${others}), pick it (\u2318K \u2192 tier)`;
3798
- }
3799
- return void 0;
3800
- }
3801
-
3802
4222
  // src/resources.ts
3803
4223
  var NON_RESOURCE_KINDS = /* @__PURE__ */ new Set(["AWS::CloudFormation::Parameter", "chant:output"]);
3804
4224
  function nonResourceEntities(ir) {
@@ -3830,6 +4250,19 @@ function resourcesByComponent(ir, known) {
3830
4250
  }
3831
4251
 
3832
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
+ }
3833
4266
  function summarizePlan(plan, byComponent, nonResource) {
3834
4267
  const componentByEntity = /* @__PURE__ */ new Map();
3835
4268
  for (const [component, resources] of Object.entries(byComponent)) {
@@ -3838,6 +4271,9 @@ function summarizePlan(plan, byComponent, nonResource) {
3838
4271
  const counts = {};
3839
4272
  const unobservedCounts = {};
3840
4273
  const runtimeCounts = {};
4274
+ const disruption = Object.fromEntries(DISRUPTION_LEVELS.map((l) => [l, 0]));
4275
+ const disruptionByComponent = {};
4276
+ let disruptionUncorrelated;
3841
4277
  let uncorrelated = 0;
3842
4278
  let unobservedUncorrelated = 0;
3843
4279
  let runtimeUncorrelated = 0;
@@ -3863,6 +4299,12 @@ function summarizePlan(plan, byComponent, nonResource) {
3863
4299
  total++;
3864
4300
  if (component) counts[component] = (counts[component] ?? 0) + 1;
3865
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);
3866
4308
  }
3867
4309
  return {
3868
4310
  env: plan.env,
@@ -3874,7 +4316,10 @@ function summarizePlan(plan, byComponent, nonResource) {
3874
4316
  unobservedUncorrelated,
3875
4317
  runtime,
3876
4318
  runtimeByComponent: runtimeCounts,
3877
- runtimeUncorrelated
4319
+ runtimeUncorrelated,
4320
+ disruption,
4321
+ disruptionByComponent,
4322
+ ...disruptionUncorrelated ? { disruptionUncorrelated } : {}
3878
4323
  };
3879
4324
  }
3880
4325
 
@@ -3890,10 +4335,10 @@ import {
3890
4335
 
3891
4336
  // src/icon-packs.ts
3892
4337
  import { readFileSync as readFileSync6 } from "node:fs";
3893
- import { dirname as dirname3, join as join7 } from "node:path";
4338
+ import { dirname as dirname3, join as join9 } from "node:path";
3894
4339
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3895
- var ICON_ROOT = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
3896
- var cache = /* @__PURE__ */ new Map();
4340
+ var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
4341
+ var cache2 = /* @__PURE__ */ new Map();
3897
4342
  function unprefix(body) {
3898
4343
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
3899
4344
  }
@@ -3912,9 +4357,9 @@ function plate(glyph) {
3912
4357
  return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
3913
4358
  }
3914
4359
  function loadIcon(rel) {
3915
- const hit = cache.get(rel);
4360
+ const hit = cache2.get(rel);
3916
4361
  if (hit) return hit;
3917
- const raw = readFileSync6(join7(ICON_ROOT, `${rel}.svg`), "utf8");
4362
+ const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
3918
4363
  const open = raw.indexOf("<svg");
3919
4364
  const openEnd = raw.indexOf(">", open);
3920
4365
  const close = raw.lastIndexOf("</svg");
@@ -3923,7 +4368,7 @@ function loadIcon(rel) {
3923
4368
  if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
3924
4369
  const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
3925
4370
  const glyph = PLATED.has(rel) ? plate(bare) : bare;
3926
- cache.set(rel, glyph);
4371
+ cache2.set(rel, glyph);
3927
4372
  return glyph;
3928
4373
  }
3929
4374
  var K8S_ICON_BY_KIND = {
@@ -4133,45 +4578,681 @@ function carveReportToIr(report) {
4133
4578
  ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
4134
4579
  }
4135
4580
  };
4136
- });
4137
- const seen = /* @__PURE__ */ new Set();
4138
- const edges = [];
4139
- for (const r of resources) {
4140
- for (const e of boundaryEdgesOf(r)) {
4141
- const direction = directionOf(e);
4142
- const from = direction === "inbound" ? e.survivor : e.carved;
4143
- const to = direction === "inbound" ? e.carved : e.survivor;
4144
- if (!known.has(from) || !known.has(to)) continue;
4145
- const key = `${from}|${to}`;
4146
- if (seen.has(key)) continue;
4147
- seen.add(key);
4148
- edges.push({
4149
- from,
4150
- to,
4151
- kind: "ref",
4152
- viaAttr: direction,
4153
- ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4154
- });
4581
+ });
4582
+ const seen = /* @__PURE__ */ new Set();
4583
+ const edges = [];
4584
+ for (const r of resources) {
4585
+ for (const e of boundaryEdgesOf(r)) {
4586
+ const direction = directionOf(e);
4587
+ const from = direction === "inbound" ? e.survivor : e.carved;
4588
+ const to = direction === "inbound" ? e.carved : e.survivor;
4589
+ if (!known.has(from) || !known.has(to)) continue;
4590
+ const key = `${from}|${to}`;
4591
+ if (seen.has(key)) continue;
4592
+ seen.add(key);
4593
+ edges.push({
4594
+ from,
4595
+ to,
4596
+ kind: "ref",
4597
+ viaAttr: direction,
4598
+ ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
4599
+ });
4600
+ }
4601
+ }
4602
+ const byStack = {};
4603
+ for (const order of ["good", "warn", "neutral"]) {
4604
+ const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4605
+ if (members.length) byStack[verdictForStatus(order)] = members;
4606
+ }
4607
+ return { nodes, edges, groups: { byStack } };
4608
+ }
4609
+ function carveNote(report, ir) {
4610
+ const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4611
+ const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4612
+ if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
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.`;
4614
+ }
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)" } : {}
4155
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] });
4156
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 = [];
4157
5158
  const byStack = {};
4158
- for (const order of ["good", "warn", "neutral"]) {
4159
- const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
4160
- if (members.length) byStack[verdictForStatus(order)] = members;
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" });
4161
5207
  }
4162
5208
  return { nodes, edges, groups: { byStack } };
4163
5209
  }
4164
- function carveNote(report, ir) {
4165
- const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
4166
- const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
4167
- if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
4168
- return `${head}. This report carries per-resource boundary COUNTS but no edge lists, so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.`;
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.`;
4169
5249
  }
4170
5250
 
4171
5251
  // src/render.ts
4172
5252
  registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
4173
5253
  registerPack({ lexicon: "helm", iconFor: helmIconFor });
4174
5254
  registerPack({ lexicon: "terraform", iconFor: () => void 0, fields: carveCardFields });
5255
+ registerPack({ lexicon: "op", iconFor: () => void 0, fields: opCardFields });
4175
5256
  function renderArchitecture(ir, byContainer, opts = {}) {
4176
5257
  const spread = Math.min(1.5, ir.edges.length / Math.max(ir.nodes.length, 1));
4177
5258
  const layout = layoutArchitecture(ir, byContainer, {
@@ -4261,7 +5342,8 @@ function bandedPlan(ir, bands) {
4261
5342
  return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
4262
5343
  }
4263
5344
  function renderCarveEstate(tfIr, appIr, opts) {
4264
- 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 });
4265
5347
  const svg = renderSvg(comp.ir, comp.layout, {
4266
5348
  fit: true,
4267
5349
  hideTitle: true,
@@ -4270,6 +5352,17 @@ function renderCarveEstate(tfIr, appIr, opts) {
4270
5352
  });
4271
5353
  return { svg, ir: comp.ir };
4272
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
+ }
4273
5366
  function namespaceAppIr(appIr, appTitle) {
4274
5367
  const prefix = `${appTitle.split(" ")[0]}/`;
4275
5368
  const appId = (id) => `${prefix}${id}`;
@@ -4282,7 +5375,7 @@ function namespaceAppIr(appIr, appTitle) {
4282
5375
  }
4283
5376
  function composeCarveEstate(tfIr, appNs, opts) {
4284
5377
  const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
4285
- 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) });
4286
5379
  const memberH = (plan) => TITLE + PAD + plan.height + PAD;
4287
5380
  const memberW = (plan) => plan.width + PAD * 2;
4288
5381
  const appX0 = memberW(tfPlan) + MEMBER_GAP;
@@ -4321,23 +5414,25 @@ function composeCarveEstate(tfIr, appNs, opts) {
4321
5414
  };
4322
5415
  }
4323
5416
  function renderCarveMorph(tfIr, appIr, carved, opts) {
4324
- const appNs = namespaceAppIr(appIr, opts.appTitle);
4325
- 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);
4326
5421
  const gone = new Set(carved);
4327
- const bands = tfIr.groups.byStack ?? {};
5422
+ const bands = staged.tf.groups.byStack ?? {};
4328
5423
  const bandsAfter = {};
4329
5424
  for (const [band, members] of Object.entries(bands)) {
4330
5425
  const left = members.filter((id) => !gone.has(id));
4331
5426
  if (left.length) bandsAfter[band] = left;
4332
5427
  }
4333
5428
  const tfAfter = {
4334
- nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
4335
- edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
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)),
4336
5431
  groups: { byStack: bandsAfter }
4337
5432
  };
4338
- 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" } }));
4339
5434
  const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
4340
- const after = composeCarveEstate(tfAfter, appAfter, opts);
5435
+ const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
4341
5436
  const view = (name, comp) => ({
4342
5437
  name,
4343
5438
  ir: comp.ir,
@@ -4599,12 +5694,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
4599
5694
  }
4600
5695
 
4601
5696
  // src/carve-actions.ts
4602
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4603
- import { isAbsolute, join as join10, relative, resolve as resolve2, sep } from "node:path";
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";
4604
5699
 
4605
5700
  // src/layout.ts
4606
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
4607
- import { join as join8 } from "node:path";
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";
4608
5703
  var LAYOUT_DIR = ".behold";
4609
5704
  var LAYOUT_FILE = "layout.json";
4610
5705
  var MAX_BODY_BYTES = 64 * 1024;
@@ -4645,7 +5740,7 @@ function lensFromQuery(params) {
4645
5740
  return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
4646
5741
  }
4647
5742
  function layoutPath(projectDir) {
4648
- return join8(projectDir, LAYOUT_DIR, LAYOUT_FILE);
5743
+ return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
4649
5744
  }
4650
5745
  function unwritableReason(projectDir) {
4651
5746
  if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
@@ -4661,7 +5756,7 @@ function readLayoutFile(projectDir) {
4661
5756
  const file = layoutPath(projectDir);
4662
5757
  try {
4663
5758
  if (!existsSync6(file)) return empty;
4664
- const raw = readFileSync7(file, "utf8");
5759
+ const raw = readFileSync8(file, "utf8");
4665
5760
  if (raw.length > MAX_FILE_BYTES) return empty;
4666
5761
  const parsed = JSON.parse(raw);
4667
5762
  if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty;
@@ -4701,7 +5796,7 @@ function writeLens(projectDir, lens, deltas) {
4701
5796
  }
4702
5797
  const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
4703
5798
  if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
4704
- mkdirSync2(join8(projectDir, LAYOUT_DIR), { recursive: true });
5799
+ mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
4705
5800
  const tmp = `${file}.tmp`;
4706
5801
  writeFileSync2(tmp, body, "utf8");
4707
5802
  renameSync(tmp, file);
@@ -4786,8 +5881,8 @@ function reanchorEdges(svg, moved) {
4786
5881
 
4787
5882
  // src/carve-live.ts
4788
5883
  import { spawn as spawn3 } from "node:child_process";
4789
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
4790
- import { join as join9 } from "node:path";
5884
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
5885
+ import { join as join12 } from "node:path";
4791
5886
  var LIVE_CONTAINER = "behold-carve-floci";
4792
5887
  var LIVE_PORT = 4602;
4793
5888
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -4846,9 +5941,9 @@ async function teardownScratchFloci() {
4846
5941
  await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
4847
5942
  }
4848
5943
  async function applyIntoFloci(fromDir, step) {
4849
- const template = join9(fromDir, "floci-override.tf.disabled");
5944
+ const template = join12(fromDir, "floci-override.tf.disabled");
4850
5945
  if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
4851
- writeFileSync3(join9(fromDir, "floci_override.tf"), armOverride(readFileSync8(template, "utf8"), LIVE_PORT));
5946
+ writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
4852
5947
  const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
4853
5948
  if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
4854
5949
  const apply = await step(
@@ -4872,7 +5967,7 @@ ${tail}`, exitCode: r.code };
4872
5967
  // src/carve-actions.ts
4873
5968
  async function runCarvePlan(demo) {
4874
5969
  if (!demo?.live) {
4875
- return refuse2(
5970
+ return refuse3(
4876
5971
  "carve-action",
4877
5972
  "terraform plan is the live tier's beat, and this server isn't serving one",
4878
5973
  "run `behold demo carve --live` (needs docker + terraform)"
@@ -4880,7 +5975,7 @@ async function runCarvePlan(demo) {
4880
5975
  }
4881
5976
  const r = await runLivePlan(demo.from);
4882
5977
  if ("error" in r) {
4883
- 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 + ".");
4884
5979
  }
4885
5980
  return {
4886
5981
  ok: true,
@@ -4892,14 +5987,14 @@ async function runCarvePlan(demo) {
4892
5987
  }
4893
5988
  async function runCarveObserve(demo, select) {
4894
5989
  if (!demo.live) {
4895
- return refuse2(
5990
+ return refuse3(
4896
5991
  "carve-action",
4897
5992
  "the observe beat reads the scratch Floci, and this server isn't serving one",
4898
5993
  "run `behold demo carve --live` (needs docker + terraform)"
4899
5994
  );
4900
5995
  }
4901
- if (!existsSync8(join10(demo.out, "src"))) {
4902
- return refuse2("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
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");
4903
5998
  }
4904
5999
  const args = ["lifecycle", "diff", "prod", "--live", "--json"];
4905
6000
  const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
@@ -4908,18 +6003,26 @@ async function runCarveObserve(demo, select) {
4908
6003
  stderr: err instanceof Error ? err.message : String(err)
4909
6004
  }));
4910
6005
  if (run3.code !== 0) {
4911
- return refuse2(
6006
+ return refuse3(
4912
6007
  "carve-action",
4913
6008
  `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
4914
6009
  "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
4915
6010
  );
4916
6011
  }
4917
- let aws;
6012
+ let parsed;
4918
6013
  try {
4919
- aws = JSON.parse(run3.stdout).lexicons?.aws ?? {};
6014
+ parsed = JSON.parse(run3.stdout);
4920
6015
  } catch {
4921
- return refuse2("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
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
+ );
4922
6024
  }
6025
+ const aws = parsed.lexicons?.aws ?? {};
4923
6026
  const entity = select.split(".").pop() ?? select;
4924
6027
  const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`;
4925
6028
  const queried = aws.resources?.queried?.[entity];
@@ -4954,13 +6057,13 @@ async function runCarveObserve(demo, select) {
4954
6057
  var MAX_ARTIFACT_BYTES = 64 * 1024;
4955
6058
  var MAX_ARTIFACTS = 24;
4956
6059
  var MAX_OUTPUT_BYTES = 32 * 1024;
4957
- var refuse2 = (code, error, remedy) => ({
6060
+ var refuse3 = (code, error, remedy) => ({
4958
6061
  ok: false,
4959
6062
  refusal: { error, code, remedy }
4960
6063
  });
4961
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.";
4962
6065
  function insideDemo(root, p) {
4963
- const rel = relative(resolve2(root), resolve2(p));
6066
+ const rel = relative2(resolve4(root), resolve4(p));
4964
6067
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
4965
6068
  }
4966
6069
  function selectFromReport(report, raw) {
@@ -4981,17 +6084,17 @@ function readArtifacts(demo, dir, filter) {
4981
6084
  if (out.length >= MAX_ARTIFACTS || depth > 2) return;
4982
6085
  let entries;
4983
6086
  try {
4984
- entries = readdirSync3(d).sort();
6087
+ entries = readdirSync5(d).sort();
4985
6088
  } catch {
4986
6089
  return;
4987
6090
  }
4988
6091
  for (const name of entries) {
4989
6092
  if (out.length >= MAX_ARTIFACTS) return;
4990
6093
  if (name === "node_modules" || name.startsWith(".")) continue;
4991
- const full = join10(d, name);
6094
+ const full = join13(d, name);
4992
6095
  let st;
4993
6096
  try {
4994
- st = statSync2(full);
6097
+ st = statSync5(full);
4995
6098
  } catch {
4996
6099
  continue;
4997
6100
  }
@@ -4999,11 +6102,11 @@ function readArtifacts(demo, dir, filter) {
4999
6102
  walk(full, depth + 1);
5000
6103
  continue;
5001
6104
  }
5002
- const rel = relative(demo.root, full).split(sep).join("/");
6105
+ const rel = relative2(demo.root, full).split(sep2).join("/");
5003
6106
  if (!filter(rel)) continue;
5004
6107
  let text = "";
5005
6108
  try {
5006
- text = readFileSync9(full, "utf8");
6109
+ text = readFileSync10(full, "utf8");
5007
6110
  } catch {
5008
6111
  continue;
5009
6112
  }
@@ -5025,17 +6128,17 @@ function carveSlug(select) {
5025
6128
  }
5026
6129
  var clip = (s) => s.length > MAX_OUTPUT_BYTES ? s.slice(0, MAX_OUTPUT_BYTES) + "\n\u2026 (truncated)" : s;
5027
6130
  function shortenIn(arg, root) {
5028
- 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;
5029
6132
  }
5030
6133
  function merge(r, root) {
5031
6134
  const joined = stripAnsi([r.stdout, r.stderr].filter((s) => s.trim()).join("\n").trim());
5032
- return clip(joined.split(root + sep).join("").split(root).join("."));
6135
+ return clip(joined.split(root + sep2).join("").split(root).join("."));
5033
6136
  }
5034
6137
  async function runCarveEmit(demo, select) {
5035
6138
  const block = carveWriteBlock(demo);
5036
- 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`.");
5037
6140
  mkdirSync3(demo.out, { recursive: true });
5038
- const reportFile = join10(demo.out, `${carveSlug(select)}-boundary.json`);
6141
+ const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
5039
6142
  const args = [
5040
6143
  "carve",
5041
6144
  "emit",
@@ -5056,7 +6159,7 @@ async function runCarveEmit(demo, select) {
5056
6159
  }));
5057
6160
  const output = merge(run3, demo.root);
5058
6161
  if (run3.code !== 0) {
5059
- return refuse2(
6162
+ return refuse3(
5060
6163
  "carve-action",
5061
6164
  `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
5062
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`."
@@ -5064,10 +6167,10 @@ async function runCarveEmit(demo, select) {
5064
6167
  }
5065
6168
  let boundary = null;
5066
6169
  try {
5067
- boundary = JSON.parse(readFileSync9(reportFile, "utf8"));
6170
+ boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
5068
6171
  } catch {
5069
6172
  }
5070
- 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("/");
5071
6174
  const lintArgs = ["lint", lintPath];
5072
6175
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
5073
6176
  code: 127,
@@ -5091,7 +6194,7 @@ async function runCarveEmit(demo, select) {
5091
6194
  }
5092
6195
  async function runCarveBridge(demo, select) {
5093
6196
  const block = carveWriteBlock(demo);
5094
- 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`.");
5095
6198
  mkdirSync3(demo.out, { recursive: true });
5096
6199
  const args = [
5097
6200
  "carve",
@@ -5111,7 +6214,7 @@ async function runCarveBridge(demo, select) {
5111
6214
  }));
5112
6215
  const output = merge(run3, demo.root);
5113
6216
  if (run3.code !== 0) {
5114
- return refuse2(
6217
+ return refuse3(
5115
6218
  "carve-action",
5116
6219
  `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
5117
6220
  "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
@@ -5130,8 +6233,8 @@ async function runCarveBridge(demo, select) {
5130
6233
  }
5131
6234
 
5132
6235
  // src/ops.ts
5133
- import { readdirSync as readdirSync4, readFileSync as readFileSync10, existsSync as existsSync9 } from "node:fs";
5134
- import { join as join11 } from "node:path";
6236
+ import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
6237
+ import { join as join14 } from "node:path";
5135
6238
  var APPLY_TARGET_LEXICON = {
5136
6239
  cloudformation: "aws",
5137
6240
  kubectl: "k8s",
@@ -5149,45 +6252,623 @@ function kindOf(content) {
5149
6252
  if (/AuditOp\b/.test(content)) return "audit";
5150
6253
  return "op";
5151
6254
  }
5152
- function discoverOps(projectDir) {
5153
- 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) {
5154
6805
  const out = [];
5155
- for (const sub of ["ops", "src", "."]) {
5156
- const dir = join11(projectDir, sub);
5157
- if (!existsSync9(dir)) continue;
5158
- for (const f of readdirSync4(dir)) {
5159
- if (!f.endsWith(".op.ts")) continue;
5160
- const content = readFileSync10(join11(dir, f), "utf8");
5161
- const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
5162
- if (!name || seen.has(name)) continue;
5163
- seen.add(name);
5164
- const gate = content.match(/signalName:\s*["'`]([^"'`]+)["'`]/)?.[1];
5165
- const env = content.match(/\benv:\s*["'`]([^"'`]+)["'`]/)?.[1];
5166
- const target = content.match(/\btarget:\s*["'`]([^"'`]+)["'`]/)?.[1];
5167
- 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;
5168
6809
  out.push({
5169
- name,
5170
- kind: kindOf(content),
5171
- dir: projectDir,
5172
- ...gate ? { gate } : {},
5173
- ...env ? { env } : {},
5174
- ...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
+ }
5175
6823
  });
5176
6824
  }
5177
6825
  }
5178
- return out.sort((a, b) => a.name.localeCompare(b.name));
6826
+ return out;
5179
6827
  }
5180
- function discoverEstateOps(projectDirs) {
5181
- const seen = /* @__PURE__ */ new Set();
5182
- const out = [];
5183
- for (const dir of projectDirs) {
5184
- for (const op of discoverOps(dir)) {
5185
- if (seen.has(op.name)) continue;
5186
- seen.add(op.name);
5187
- out.push(op);
5188
- }
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 };
5189
6839
  }
5190
- 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("; ")}.`;
5191
6872
  }
5192
6873
 
5193
6874
  // src/adopt.ts
@@ -5270,21 +6951,21 @@ function classifyHealth(status) {
5270
6951
  if (HEALTHY.test(text)) return "healthy";
5271
6952
  return "unknown";
5272
6953
  }
5273
- function rec2(v) {
6954
+ function rec4(v) {
5274
6955
  return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
5275
6956
  }
5276
- function str2(v) {
6957
+ function str4(v) {
5277
6958
  return typeof v === "string" && v.length > 0 ? v : void 0;
5278
6959
  }
5279
6960
  function statusTree(o) {
5280
- const attrs = rec2(o.attributes);
6961
+ const attrs = rec4(o.attributes);
5281
6962
  if (!attrs) return void 0;
5282
- return rec2(attrs.status) ?? attrs;
6963
+ return rec4(attrs.status) ?? attrs;
5283
6964
  }
5284
6965
  function specTree(o) {
5285
- const attrs = rec2(o.attributes);
6966
+ const attrs = rec4(o.attributes);
5286
6967
  if (!attrs) return void 0;
5287
- return rec2(attrs.spec) ?? attrs;
6968
+ return rec4(attrs.spec) ?? attrs;
5288
6969
  }
5289
6970
  var ARGO_HEALTH = {
5290
6971
  Healthy: "healthy",
@@ -5310,9 +6991,9 @@ function argoErrorMessage(tree) {
5310
6991
  if (m) return m[2];
5311
6992
  continue;
5312
6993
  }
5313
- const type = str2(rec2(c)?.type);
6994
+ const type = str4(rec4(c)?.type);
5314
6995
  if (!type || !ARGO_ERROR_CONDITION.test(type)) continue;
5315
- const message = str2(rec2(c)?.message);
6996
+ const message = str4(rec4(c)?.message);
5316
6997
  if (message) return message;
5317
6998
  }
5318
6999
  return void 0;
@@ -5320,10 +7001,10 @@ function argoErrorMessage(tree) {
5320
7001
  function argoVerdict(o) {
5321
7002
  if (o.type !== "K8s::Argo::Application") return void 0;
5322
7003
  const tree = statusTree(o);
5323
- let health = str2(rec2(tree?.health)?.status);
5324
- let sync = str2(rec2(tree?.sync)?.status);
7004
+ let health = str4(rec4(tree?.health)?.status);
7005
+ let sync = str4(rec4(tree?.sync)?.status);
5325
7006
  if (!health && !sync) {
5326
- const word = str2(o.status);
7007
+ const word = str4(o.status);
5327
7008
  if (word === "READY") {
5328
7009
  health = "Healthy";
5329
7010
  sync = "Synced";
@@ -5349,16 +7030,16 @@ function readyFromConditions(tree) {
5349
7030
  const conditions = tree?.conditions;
5350
7031
  if (!Array.isArray(conditions)) return void 0;
5351
7032
  for (const c of conditions) {
5352
- const cond = rec2(c);
7033
+ const cond = rec4(c);
5353
7034
  if (!cond || cond.type !== "Ready") continue;
5354
- const status = str2(cond.status);
7035
+ const status = str4(cond.status);
5355
7036
  if (status !== "True" && status !== "False" && status !== "Unknown") continue;
5356
- return { status, reason: str2(cond.reason) };
7037
+ return { status, reason: str4(cond.reason) };
5357
7038
  }
5358
7039
  return void 0;
5359
7040
  }
5360
7041
  function readyFromChantConditions(o) {
5361
- const conditions = rec2(o.attributes)?.conditions;
7042
+ const conditions = rec4(o.attributes)?.conditions;
5362
7043
  if (!Array.isArray(conditions)) return void 0;
5363
7044
  for (const c of conditions) {
5364
7045
  if (typeof c !== "string") continue;
@@ -5370,7 +7051,7 @@ function readyFromChantConditions(o) {
5370
7051
  return void 0;
5371
7052
  }
5372
7053
  function readyFromStatusWord(o) {
5373
- const word = str2(o.status);
7054
+ const word = str4(o.status);
5374
7055
  if (!word || word === "PRESENT") return void 0;
5375
7056
  if (word === "READY") return { status: "True" };
5376
7057
  if (word === "NOT-READY") return { status: "False" };
@@ -5380,8 +7061,8 @@ function describeReady(ready) {
5380
7061
  return `Ready=${ready.status}${ready.reason ? ` (${ready.reason})` : ""}`;
5381
7062
  }
5382
7063
  function revisionVerdict(tree) {
5383
- const applied = str2(tree?.lastAppliedRevision);
5384
- const attempted = str2(tree?.lastAttemptedRevision);
7064
+ const applied = str4(tree?.lastAppliedRevision);
7065
+ const attempted = str4(tree?.lastAttemptedRevision);
5385
7066
  if (!applied || !attempted || applied === attempted) return void 0;
5386
7067
  return { health: "progressing", detail: `applied ${applied}, attempted ${attempted}` };
5387
7068
  }
@@ -5394,23 +7075,23 @@ function fluxVerdict(o) {
5394
7075
  if (ready?.status === "False") return { health: "degraded", detail: describeReady(ready) };
5395
7076
  if (ready?.status === "Unknown") return { health: "progressing", detail: describeReady(ready) };
5396
7077
  if (ready?.status === "True") return revisionVerdict(tree) ?? { health: "healthy", detail: describeReady(ready) };
5397
- if (o.type === FLUX_HELMREPOSITORY_TYPE && str2(specTree(o)?.type) === "oci") {
7078
+ if (o.type === FLUX_HELMREPOSITORY_TYPE && str4(specTree(o)?.type) === "oci") {
5398
7079
  return { health: "healthy", detail: "spec.type=oci (nothing to reconcile)" };
5399
7080
  }
5400
7081
  const conditions = tree?.conditions;
5401
7082
  const reconciling = Array.isArray(conditions) && conditions.some((c) => {
5402
- const cond = rec2(c);
7083
+ const cond = rec4(c);
5403
7084
  return cond?.type === "Reconciling" && cond.status === "True";
5404
7085
  });
5405
7086
  if (reconciling) return { health: "progressing", detail: "Reconciling=True" };
5406
- if (!str2(o.status) || o.status === "PRESENT") {
7087
+ if (!str4(o.status) || o.status === "PRESENT") {
5407
7088
  return { health: "progressing", detail: "no Ready condition yet" };
5408
7089
  }
5409
7090
  return void 0;
5410
7091
  }
5411
7092
  var DEPLOYMENT_TYPE = "K8s::Apps::Deployment";
5412
7093
  function unhappyCondition(o, type) {
5413
- const fromStrings = rec2(o.attributes)?.conditions;
7094
+ const fromStrings = rec4(o.attributes)?.conditions;
5414
7095
  if (Array.isArray(fromStrings)) {
5415
7096
  for (const c of fromStrings) {
5416
7097
  if (typeof c === "string" && c.startsWith(type)) return c;
@@ -5420,13 +7101,13 @@ function unhappyCondition(o, type) {
5420
7101
  const raw = tree?.conditions;
5421
7102
  if (Array.isArray(raw)) {
5422
7103
  for (const c of raw) {
5423
- const cond = rec2(c);
7104
+ const cond = rec4(c);
5424
7105
  if (!cond || cond.type !== type) continue;
5425
- const status = str2(cond.status);
7106
+ const status = str4(cond.status);
5426
7107
  const unhappy = type === "ReplicaFailure" ? status === "True" : status === "False";
5427
7108
  if (!unhappy) continue;
5428
- const reason = str2(cond.reason);
5429
- const message = str2(cond.message);
7109
+ const reason = str4(cond.reason);
7110
+ const message = str4(cond.message);
5430
7111
  return `${type}${reason ? `=${reason}` : ""}${message ? `: ${message}` : ""}`;
5431
7112
  }
5432
7113
  }
@@ -5555,6 +7236,19 @@ var OpRunner = class {
5555
7236
  * before the first event lands so a reload between trigger and first event
5556
7237
  * doesn't show the PREVIOUS run's stale terminal state). */
5557
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;
5558
7252
  /** #195: re-point delegated writes at a switched project. The runner reads
5559
7253
  * `deps.projectDir` at trigger time, so this is the whole retarget. */
5560
7254
  retarget(projectDir) {
@@ -5569,6 +7263,32 @@ var OpRunner = class {
5569
7263
  get applyProgress() {
5570
7264
  return this.lastApplyProgress;
5571
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
+ }
5572
7292
  /**
5573
7293
  * Start `chant run <name>` unless one is already running (the Sync/Adopt/auto-
5574
7294
  * sync path). `cwd` is the Op's own project dir (#31 multi-estate); defaults to
@@ -5578,9 +7298,78 @@ var OpRunner = class {
5578
7298
  * set it from the Op's declared gate: chant refuses a gated Op outright in
5579
7299
  * local mode ("gates and schedules need a durable runtime"), so without the
5580
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.
5581
7309
  */
5582
7310
  trigger(name, opEnv, cwd, temporal) {
5583
- return this.start(temporal ? ["run", name, "--temporal"] : ["run", name], name, opEnv, cwd);
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.
7361
+ */
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;
5584
7373
  }
5585
7374
  /**
5586
7375
  * Run an arbitrary `chant` invocation through the same guard/stream/PR/capture
@@ -5712,8 +7501,12 @@ var OpRunner = class {
5712
7501
  * fully handled that line (apply()'s progress-JSON parsing) — `start()` then
5713
7502
  * skips its own `op`/`pr` broadcast for that one line, leaving every other
5714
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.
5715
7508
  */
5716
- start(args, label, opEnv, cwd, onLine) {
7509
+ start(args, label, opEnv, cwd, onLine, onExit) {
5717
7510
  if (this.current) return false;
5718
7511
  const { projectDir, broadcaster } = this.deps;
5719
7512
  broadcaster.emit("op", `\u25B6 chant ${args.join(" ")}`);
@@ -5726,6 +7519,7 @@ var OpRunner = class {
5726
7519
  this.current = label;
5727
7520
  void op.done.then((code) => {
5728
7521
  broadcaster.emit("op", `\u25A0 ${label} exited ${code}`);
7522
+ onExit?.(code);
5729
7523
  this.current = null;
5730
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)}`));
5731
7525
  });
@@ -5735,24 +7529,24 @@ var OpRunner = class {
5735
7529
 
5736
7530
  // src/substrates.ts
5737
7531
  import { spawn as spawn4 } from "node:child_process";
5738
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
5739
- import { join as join12 } from "node:path";
7532
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
7533
+ import { join as join15 } from "node:path";
5740
7534
  import { platform } from "node:os";
5741
7535
  var DOCKER_DOWN_DETAIL = "docker is down";
5742
7536
  function probe(cmd, args) {
5743
- return new Promise((resolve8) => {
7537
+ return new Promise((resolve9) => {
5744
7538
  let out = "";
5745
7539
  let proc;
5746
7540
  try {
5747
7541
  proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5748
7542
  } catch {
5749
- resolve8({ code: 127, out: "" });
7543
+ resolve9({ code: 127, out: "" });
5750
7544
  return;
5751
7545
  }
5752
7546
  proc.stdout.on("data", (d) => out += d);
5753
7547
  proc.stderr.on("data", (d) => out += d);
5754
- proc.on("error", () => resolve8({ code: 127, out }));
5755
- proc.on("close", (code) => resolve8({ code: code ?? 1, out }));
7548
+ proc.on("error", () => resolve9({ code: 127, out }));
7549
+ proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
5756
7550
  });
5757
7551
  }
5758
7552
  async function dockerAvailable(run3) {
@@ -5765,11 +7559,11 @@ async function dockerRunning(run3, nameFilter) {
5765
7559
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
5766
7560
  }
5767
7561
  function scriptBringUp(projectDir, relPath, label) {
5768
- 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;
5769
7563
  }
5770
7564
  function projectLexicons(projectDir) {
5771
7565
  try {
5772
- const src = readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8");
7566
+ const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
5773
7567
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
5774
7568
  if (!m) return [];
5775
7569
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -5821,7 +7615,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5821
7615
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
5822
7616
  ];
5823
7617
  for (const [name, label, marker, script] of forges) {
5824
- if (!existsSync10(join12(projectDir, marker))) continue;
7618
+ if (!existsSync10(join15(projectDir, marker))) continue;
5825
7619
  const c = docker ? await dockerRunning(run3, name) : [];
5826
7620
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
5827
7621
  subs.push({
@@ -5855,7 +7649,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5855
7649
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
5856
7650
  });
5857
7651
  }
5858
- if (existsSync10(join12(projectDir, ".github", "workflows"))) {
7652
+ if (existsSync10(join15(projectDir, ".github", "workflows"))) {
5859
7653
  const gh = await run3("gh", ["auth", "status"]);
5860
7654
  const ready = gh.code === 0;
5861
7655
  subs.push({
@@ -5868,7 +7662,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5868
7662
  if (lexicons.includes("temporal")) {
5869
7663
  let hasProfiles = false;
5870
7664
  try {
5871
- 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"));
5872
7666
  } catch {
5873
7667
  }
5874
7668
  subs.push({
@@ -5971,185 +7765,9 @@ async function openRollbackBranches(projectDir, env) {
5971
7765
  }
5972
7766
  }
5973
7767
 
5974
- // src/estate.ts
5975
- import { statSync as statSync3 } from "node:fs";
5976
- import { availableParallelism } from "node:os";
5977
- import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
5978
- import { composeStacks, shortStackNames } from "@intentius/pinhole";
5979
- function estateReadPool(members, env = process.env) {
5980
- const override = Number.parseInt(env.BEHOLD_ESTATE_CONCURRENCY ?? "", 10);
5981
- const cap = Number.isInteger(override) && override >= 1 ? override : Math.min(4, availableParallelism());
5982
- return Math.max(1, Math.min(cap, members));
5983
- }
5984
- async function mapPool(items, width, fn) {
5985
- const results = new Array(items.length);
5986
- let next = 0;
5987
- const worker = async () => {
5988
- for (let i = next++; i < items.length; i = next++) {
5989
- results[i] = await fn(items[i], i);
5990
- }
5991
- };
5992
- await Promise.all(Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => worker()));
5993
- return results;
5994
- }
5995
- async function composeEstate(projectDirs, opts = {}) {
5996
- const names = shortStackNames(projectDirs);
5997
- const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
5998
- name: names[i],
5999
- ir: await graphIr(dir, opts)
6000
- }));
6001
- return composeStacks(stacks);
6002
- }
6003
- var firstLine2 = (e) => {
6004
- const msg = e instanceof Error ? e.message : String(e);
6005
- const m = msg.match(/exited \d+:\s*([\s\S]*)/);
6006
- return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
6007
- };
6008
- function namespaceRuntimeOwners(name, ir) {
6009
- const own = new Set(ir.nodes.map((n) => n.id));
6010
- for (const n of ir.nodes) {
6011
- if (n.runtimeOwner && own.has(n.runtimeOwner)) n.runtimeOwner = `${name}/${n.runtimeOwner}`;
6012
- }
6013
- return ir;
6014
- }
6015
- var NAMESPACE_JOIN_FLOOR = "0.44.5";
6016
- var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
6017
- var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
6018
- var realIsDir = (p) => {
6019
- try {
6020
- return statSync3(p).isDirectory();
6021
- } catch {
6022
- return false;
6023
- }
6024
- };
6025
- function declaredNamespaceBindings(ir) {
6026
- const out = [];
6027
- for (const n of ir.nodes) {
6028
- if (n.kind !== "K8s::Flux::Kustomization") continue;
6029
- const spec = rec3(n.attrs?.spec);
6030
- const namespace = str3(spec?.targetNamespace);
6031
- const path = str3(spec?.path);
6032
- if (namespace && path) out.push({ path, namespace });
6033
- }
6034
- return out;
6035
- }
6036
- function awaitsNamespaceBinding(ir) {
6037
- const namespaced = ir.nodes.filter((n) => n.lexicon === "k8s" && !CLUSTER_SCOPED.has(n.kind ?? ""));
6038
- if (namespaced.length === 0) return false;
6039
- return namespaced.every((n) => !str3(rec3(n.attrs?.metadata)?.namespace));
6040
- }
6041
- function declaredSegments(path) {
6042
- if (path.startsWith("/")) return void 0;
6043
- const segs = path.split("/").filter((s) => s !== "" && s !== ".");
6044
- if (segs.length === 0 || segs.includes("..")) return void 0;
6045
- return segs;
6046
- }
6047
- function pathAlignment(declared, dir, isDir = realIsDir) {
6048
- const want = declaredSegments(declared);
6049
- if (!want) return 0;
6050
- const root = resolve3(dir);
6051
- const have = root.split(sep2).filter(Boolean);
6052
- for (let k = Math.min(want.length, have.length); k >= 1; k--) {
6053
- if (!have.slice(-k).every((s, i) => s === want[i])) continue;
6054
- if (!isDir(join13(root, ...want.slice(k)))) continue;
6055
- return k;
6056
- }
6057
- return 0;
6058
- }
6059
- function joinNamespaceBindings(members, isDir = realIsDir) {
6060
- const bindings = members.flatMap(
6061
- (m) => m.ir ? declaredNamespaceBindings(m.ir).map((b) => ({ ...b, declaredBy: m.dir })) : []
6062
- );
6063
- if (bindings.length === 0) return [];
6064
- const awaiting = members.filter((m) => m.ir && awaitsNamespaceBinding(m.ir));
6065
- if (awaiting.length === 0) return [];
6066
- const best = /* @__PURE__ */ new Map();
6067
- for (const b of bindings) {
6068
- let winner;
6069
- let tied = false;
6070
- for (const m of awaiting) {
6071
- if (m.dir === b.declaredBy) continue;
6072
- const score = pathAlignment(b.path, m.dir, isDir);
6073
- if (score === 0) continue;
6074
- if (!winner || score > winner.score) {
6075
- winner = { dir: m.dir, score };
6076
- tied = false;
6077
- } else if (score === winner.score) {
6078
- tied = true;
6079
- }
6080
- }
6081
- if (!winner || tied) continue;
6082
- const prev = best.get(winner.dir);
6083
- if (prev === "ambiguous") continue;
6084
- const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
6085
- if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
6086
- else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
6087
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
6088
- }
6089
- return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
6090
- }
6091
- async function estateNamespaceScopes(projectDirs, opts, isDir = realIsDir) {
6092
- if (projectDirs.length < 2) return /* @__PURE__ */ new Map();
6093
- const { live: _live, overlay: _overlay, namespace: _namespace, ...src } = opts;
6094
- const irs = await mapPool(
6095
- projectDirs,
6096
- estateReadPool(projectDirs.length),
6097
- (dir) => graphIr(dir, { ...src, detail: 3 }).catch(() => void 0)
6098
- );
6099
- const scopes = /* @__PURE__ */ new Map();
6100
- for (const j of joinNamespaceBindings(projectDirs.map((dir, i) => ({ dir, ir: irs[i] })), isDir)) {
6101
- if (!meetsFloor(resolveChant(j.dir).version, NAMESPACE_JOIN_FLOOR)) continue;
6102
- scopes.set(j.dir, j.namespace);
6103
- }
6104
- return scopes;
6105
- }
6106
- function withoutJoinedMembers(nodes, joined) {
6107
- if (joined.length === 0) return [...nodes];
6108
- return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
6109
- }
6110
- async function composeEstateOverlay(projectDirs, opts, classify) {
6111
- const names = shortStackNames(projectDirs);
6112
- const unobserved = [];
6113
- const dropped = [];
6114
- const joined = [];
6115
- const stacks = new Array(projectDirs.length);
6116
- const scopes = await estateNamespaceScopes(projectDirs, opts);
6117
- await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
6118
- const name = names[i];
6119
- const namespace = scopes.get(dir);
6120
- try {
6121
- const live = { ...opts, live: true, overlay: true, ...namespace ? { namespace } : {} };
6122
- stacks[i] = { name, ir: namespaceRuntimeOwners(name, classify(await graphIr(dir, live))) };
6123
- if (namespace) joined.push({ name, namespace });
6124
- } catch (err) {
6125
- const reason = firstLine2(err);
6126
- try {
6127
- const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
6128
- const src = await graphIr(dir, srcOpts);
6129
- for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
6130
- stacks[i] = { name, ir: src };
6131
- unobserved.push({ name, reason });
6132
- } catch (err2) {
6133
- dropped.push({ name, reason: firstLine2(err2) });
6134
- }
6135
- }
6136
- });
6137
- const present = stacks.filter((s) => !!s);
6138
- return {
6139
- ir: composeStacks(present),
6140
- observed: present.length - unobserved.length,
6141
- total: projectDirs.length,
6142
- unobserved,
6143
- dropped,
6144
- // Concurrent reads finish in whatever order the clusters answer; the note
6145
- // this feeds should read the same twice.
6146
- joined: joined.sort((a, b) => a.name.localeCompare(b.name))
6147
- };
6148
- }
6149
-
6150
7768
  // src/events.ts
6151
7769
  import { watch, existsSync as existsSync11 } from "node:fs";
6152
- import { join as join14 } from "node:path";
7770
+ import { join as join16 } from "node:path";
6153
7771
  var Broadcaster = class {
6154
7772
  listeners = /* @__PURE__ */ new Set();
6155
7773
  subscribe(fn) {
@@ -6167,7 +7785,7 @@ var Broadcaster = class {
6167
7785
  };
6168
7786
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
6169
7787
  function watchSource(projectDir, onChange, debounceMs = 200) {
6170
- const dir = existsSync11(join14(projectDir, "src")) ? join14(projectDir, "src") : projectDir;
7788
+ const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
6171
7789
  let timer;
6172
7790
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
6173
7791
  const name = typeof file === "string" ? file : "";
@@ -6416,8 +8034,8 @@ function renderLanes(frames, summaries) {
6416
8034
  status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
6417
8035
  lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
6418
8036
  }));
6419
- const strip = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
6420
- return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
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;
6421
8039
  }
6422
8040
 
6423
8041
  // src/emulator.ts
@@ -6455,13 +8073,13 @@ async function emulatorDown(projectDir) {
6455
8073
  }
6456
8074
 
6457
8075
  // src/demos.ts
6458
- import { readFileSync as readFileSync12, existsSync as existsSync12, cpSync } from "node:fs";
6459
- import { join as join15, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
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";
6460
8078
  import { spawn as spawn5, spawnSync } from "node:child_process";
6461
8079
  function loadDemoRegistry(pkgRoot2) {
6462
8080
  let raw;
6463
8081
  try {
6464
- raw = JSON.parse(readFileSync12(join15(pkgRoot2, "demos.json"), "utf8"));
8082
+ raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
6465
8083
  } catch {
6466
8084
  return [];
6467
8085
  }
@@ -6501,22 +8119,22 @@ function fetchesFromNetwork(entry) {
6501
8119
  return entry.source === "git";
6502
8120
  }
6503
8121
  function demoTargetDir(entry, cwd = process.cwd()) {
6504
- const legacy = resolve4(cwd, "behold-demo");
8122
+ const legacy = resolve5(cwd, "behold-demo");
6505
8123
  if (entry.name === "writes" && existsSync12(legacy)) return legacy;
6506
- return resolve4(cwd, "behold-demos", entry.name);
8124
+ return resolve5(cwd, "behold-demos", entry.name);
6507
8125
  }
6508
8126
  async function loadDemo(entry, opts) {
6509
8127
  const { pkgRoot: pkgRoot2, target } = opts;
6510
8128
  const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6511
8129
  if (!existsSync12(target)) {
6512
8130
  if (entry.source === "bundled") {
6513
- const bundled = join15(pkgRoot2, entry.dir);
8131
+ const bundled = join17(pkgRoot2, entry.dir);
6514
8132
  if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6515
8133
  say(`copying to ${target} (it's yours \u2014 edit it)`);
6516
8134
  try {
6517
8135
  cpSync(bundled, target, {
6518
8136
  recursive: true,
6519
- filter: (src) => !relative2(bundled, src).split(sep3).includes("node_modules")
8137
+ filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
6520
8138
  });
6521
8139
  } catch (err) {
6522
8140
  return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -6530,7 +8148,7 @@ async function loadDemo(entry, opts) {
6530
8148
  } else {
6531
8149
  say(`reusing ${target}`);
6532
8150
  }
6533
- if (existsSync12(join15(target, "package.json")) && !existsSync12(join15(target, "node_modules"))) {
8151
+ if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
6534
8152
  say("npm install\u2026");
6535
8153
  if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6536
8154
  return { ok: false, error: `npm install failed in ${target}` };
@@ -6542,7 +8160,7 @@ async function loadDemo(entry, opts) {
6542
8160
  return { ok: false, error: `setup failed (${entry.setup})` };
6543
8161
  }
6544
8162
  }
6545
- 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] };
6546
8164
  }
6547
8165
  function runStep(cmd, args, opts = {}) {
6548
8166
  return new Promise((res) => {
@@ -6553,8 +8171,8 @@ function runStep(cmd, args, opts = {}) {
6553
8171
  }
6554
8172
 
6555
8173
  // src/server.ts
6556
- var pkgRoot = join16(dirname4(fileURLToPath3(import.meta.url)), "..");
6557
- var webRoot = join16(pkgRoot, "web");
8174
+ var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
8175
+ var webRoot = join18(pkgRoot, "web");
6558
8176
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6559
8177
  function optsFromQuery(url, tierEnvVar, projectDir) {
6560
8178
  const q = url.searchParams;
@@ -6603,8 +8221,8 @@ function tierFailure(tier, message) {
6603
8221
  }
6604
8222
  function beholdVersion() {
6605
8223
  try {
6606
- const here = dirname4(fileURLToPath3(import.meta.url));
6607
- 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";
6608
8226
  } catch {
6609
8227
  return "unknown";
6610
8228
  }
@@ -6649,7 +8267,9 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
6649
8267
  }
6650
8268
  }
6651
8269
  function carveRoutes(app, reportPath, demo) {
6652
- 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);
6653
8273
  const demoBlock = () => carveWriteBlock(demo);
6654
8274
  const demoInfo = () => {
6655
8275
  if (!demo) return null;
@@ -6661,8 +8281,8 @@ function carveRoutes(app, reportPath, demo) {
6661
8281
  project: demo.project,
6662
8282
  out: demo.out,
6663
8283
  // Paths the UI can print without leaking the operator's whole home dir.
6664
- outLabel: relative3(demo.root, demo.out).split(sep4).join("/"),
6665
- fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
8284
+ outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
8285
+ fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
6666
8286
  runnable: !block,
6667
8287
  ...block ? { reason: block } : {},
6668
8288
  // The live tier, when this boot armed it: the scratch endpoint the
@@ -6679,7 +8299,7 @@ function carveRoutes(app, reportPath, demo) {
6679
8299
  let appGraph;
6680
8300
  const appGraphOnce = () => {
6681
8301
  if (!demo) return Promise.resolve(null);
6682
- appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative3(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
8302
+ appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
6683
8303
  return appGraph;
6684
8304
  };
6685
8305
  app.get("/api/graph", async (c) => {
@@ -6687,10 +8307,21 @@ function carveRoutes(app, reportPath, demo) {
6687
8307
  if (!parsed.ok) return c.json(parsed.refusal, 422);
6688
8308
  const tfIr = carveReportToIr(parsed.report);
6689
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
+ };
6690
8316
  const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
6691
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6692
- appTitle: `${appSide.label} \u2014 chant`
6693
- }) : { ...renderBanded(tfIr), ir: tfIr };
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
+ })();
6694
8325
  return c.json({
6695
8326
  ir,
6696
8327
  svg,
@@ -6702,7 +8333,7 @@ function carveRoutes(app, reportPath, demo) {
6702
8333
  carve: true,
6703
8334
  // A demo whose own advisor run failed says so on the statusbar, not
6704
8335
  // only in the terminal the viewer isn't looking at.
6705
- 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}` : "")
6706
8337
  }
6707
8338
  });
6708
8339
  });
@@ -6734,14 +8365,22 @@ function carveRoutes(app, reportPath, demo) {
6734
8365
  }
6735
8366
  const tfIr = carveReportToIr(parsed.report);
6736
8367
  const html = renderCarveMorph(tfIr, appSide.ir, [select], {
6737
- tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8368
+ tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6738
8369
  appTitle: `${appSide.label} \u2014 chant`,
6739
- title: `carve \u2014 ${select}`
8370
+ title: `carve \u2014 ${select}`,
8371
+ carved: carveStates(),
8372
+ shorten: (p) => shortenIn(p, demo.root)
6740
8373
  });
6741
8374
  return c.html(html);
6742
8375
  });
6743
8376
  app.get("/api/project", (c) => {
6744
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
+ );
6745
8384
  return c.json({
6746
8385
  projectDir: reportPath,
6747
8386
  recents: [],
@@ -6755,11 +8394,12 @@ function carveRoutes(app, reportPath, demo) {
6755
8394
  carve: parsed.ok ? {
6756
8395
  report: reportPath,
6757
8396
  from: parsed.report.from ?? null,
6758
- count: parsed.report.count ?? parsed.report.resources.length,
8397
+ count: total,
6759
8398
  bands: parsed.report.bands ?? {},
6760
8399
  advisory: parsed.report.advisory ?? null,
6761
- demo: demoInfo()
6762
- } : { report: reportPath, demo: demoInfo() }
8400
+ demo: demoInfo(),
8401
+ state
8402
+ } : { report: reportPath, demo: demoInfo(), state }
6763
8403
  });
6764
8404
  });
6765
8405
  const runStep2 = async (c, run3) => {
@@ -6869,7 +8509,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6869
8509
  // of starting blank — the `apply` SSE event (below) carries every update
6870
8510
  // after that. `status: "idle"` (initialApplyProgress) when nothing has
6871
8511
  // applied yet this session.
6872
- 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
6873
8523
  })
6874
8524
  );
6875
8525
  app.post("/api/ops/:name/run", (c) => {
@@ -6910,8 +8560,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6910
8560
  return c.json({ started: true, name, ran: label });
6911
8561
  });
6912
8562
  app.post("/api/local/reset", (c) => {
6913
- const down = join16(cfg.projectDir, "scripts/local/local-down.sh");
6914
- const up = join16(cfg.projectDir, "scripts/local/local-up.sh");
8563
+ const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
8564
+ const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
6915
8565
  if (!existsSync13(down) || !existsSync13(up)) {
6916
8566
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
6917
8567
  }
@@ -6920,6 +8570,62 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6920
8570
  }
6921
8571
  return c.json({ started: true, ran: "local-down + local-up" });
6922
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
+ });
6923
8629
  app.post("/api/ops/:name/signal/:gate", async (c) => {
6924
8630
  const { name, gate } = c.req.param();
6925
8631
  const info = estateOps().find((o) => o.name === name);
@@ -6946,8 +8652,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6946
8652
  const { environments, lexicons, stacks, k8sProfiles } = await detectProject(cfg.projectDir);
6947
8653
  const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
6948
8654
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
8655
+ const emittedOps = discoverOpIrs(estateDirs).length;
8656
+ const carveState = carveStatePayload(readCarveStates(estateDirs));
6949
8657
  return c.json({
6950
8658
  projectDir: cfg.projectDir,
8659
+ ...carveState.manifests ? { carve: { state: carveState } } : {},
6951
8660
  // #195: the full estate composition (multi-project serves) and the
6952
8661
  // switcher's recents, so the SPA's project section can show what's
6953
8662
  // loaded and offer where to go. Recents exclude nothing here — the SPA
@@ -6978,6 +8687,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6978
8687
  // the names — `graphPath` (chant.ts) resolves a picked name to its `src`
6979
8688
  // server-side; the SPA never needs the path.
6980
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 } : {},
6981
8696
  targets: deployTargets(lexicons, k8sTarget),
6982
8697
  // Where the k8s binding came from, so the SPA never implies behold chose
6983
8698
  // it (#106). Absent for a project with no k8s lexicon or no resolvable
@@ -6989,9 +8704,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6989
8704
  app.post("/api/project/open", async (c) => {
6990
8705
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
6991
8706
  const body = await c.req.json().catch(() => ({}));
6992
- 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()) : "";
6993
8708
  if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6994
- if (!existsSync13(join16(dir, "chant.config.ts"))) {
8709
+ if (!existsSync13(join18(dir, "chant.config.ts"))) {
6995
8710
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
6996
8711
  }
6997
8712
  switchServedProject([dir]);
@@ -7011,7 +8726,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7011
8726
  };
7012
8727
  app.post("/api/project/reveal", async (c) => {
7013
8728
  const body = await c.req.json().catch(() => ({}));
7014
- 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;
7015
8730
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
7016
8731
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
7017
8732
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -7079,7 +8794,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7079
8794
  const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
7080
8795
  if (!loaded.ok) return c.json({ error: loaded.error }, 500);
7081
8796
  const dirs = loaded.serveDirs;
7082
- if (!existsSync13(join16(dirs[0], "chant.config.ts"))) {
8797
+ if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
7083
8798
  return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
7084
8799
  }
7085
8800
  if (entry.serve.local) {
@@ -7177,7 +8892,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7177
8892
  path: "/api/demos/open",
7178
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)"
7179
8894
  },
7180
- { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
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" },
7181
8896
  ...cfg.carveReport ? [
7182
8897
  { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
7183
8898
  {
@@ -7199,14 +8914,17 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7199
8914
  { method: "GET", path: "/api/resources", desc: "component \u2192 declared resources" },
7200
8915
  { method: "GET", path: "/api/ci", desc: "generated CI pipeline projection {stages, jobs, forge}" },
7201
8916
  { method: "GET", path: "/api/substrates", desc: "substrate readiness {substrates: [{name, label, status, detail, bringUp?}]}" },
7202
- { method: "GET", path: "/api/ops", desc: "committed Ops + adopt lexicons + apply progress" },
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" },
7203
8920
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
7204
8921
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
7205
- { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / pr" },
8922
+ { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
7206
8923
  { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
7207
8924
  { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
7208
8925
  { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
7209
- { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate" },
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)" },
7210
8928
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
7211
8929
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
7212
8930
  { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
@@ -7218,6 +8936,58 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7218
8936
  const url = new URL(c.req.url);
7219
8937
  const opts = optsFromQuery(url, tierEnvVar, cfg.projectDir);
7220
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
+ }
7221
8991
  const components = url.searchParams.get("components") === "1";
7222
8992
  const logical = url.searchParams.get("logical") === "1";
7223
8993
  const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
@@ -7228,6 +8998,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7228
8998
  ir = await composeEstate(cfg.projectDirs, logical ? { ...opts, detail: 3 } : opts);
7229
8999
  ir = addValueMatchEdges(ir);
7230
9000
  ir = addK8sDeclaredEdges(ir);
9001
+ ir = markOperatorHome(ir);
7231
9002
  const estateContext = await boundK8sContext(metaEnv ?? void 0);
7232
9003
  ir = addClusterAnchorEdges(ir, estateContext);
7233
9004
  if (logical) {
@@ -7272,6 +9043,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7272
9043
  if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
7273
9044
  ir = addValueMatchEdges(ir);
7274
9045
  ir = addK8sDeclaredEdges(ir);
9046
+ ir = markOperatorHome(ir);
7275
9047
  ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
7276
9048
  }
7277
9049
  let srcCompositeEdgesAttached;
@@ -7285,7 +9057,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7285
9057
  srcCompositeEdgesAttached = 0;
7286
9058
  }
7287
9059
  }
7288
- 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"))) {
7289
9061
  return c.json(noProjectError(cfg.projectDir), 404);
7290
9062
  }
7291
9063
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
@@ -7403,6 +9175,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7403
9175
  if ((detail ?? 2) < 3) ir2 = pruneImports(ir2);
7404
9176
  ir2 = addValueMatchEdges(ir2);
7405
9177
  ir2 = addK8sDeclaredEdges(ir2);
9178
+ ir2 = markOperatorHome(ir2);
7406
9179
  const boundContext2 = await boundK8sContext(env);
7407
9180
  ir2 = addClusterAnchorEdges(ir2, boundContext2);
7408
9181
  const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
@@ -7448,6 +9221,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7448
9221
  const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7449
9222
  applyHelmArtifacts(ir, observed);
7450
9223
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
9224
+ applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
7451
9225
  }
7452
9226
  ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
7453
9227
  if (logical) {
@@ -7461,6 +9235,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7461
9235
  if ((query.detail ?? 2) < 3) ir = pruneImports(ir);
7462
9236
  ir = addValueMatchEdges(ir);
7463
9237
  ir = addK8sDeclaredEdges(ir);
9238
+ ir = markOperatorHome(ir);
7464
9239
  ir = addClusterAnchorEdges(ir, boundContext);
7465
9240
  let compositeEdgesAttached;
7466
9241
  if (query.detail === 1) {
@@ -7496,6 +9271,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7496
9271
  const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
7497
9272
  applyHelmArtifacts(result.ir, observed);
7498
9273
  synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
9274
+ applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
7499
9275
  }
7500
9276
  }
7501
9277
  if (env) {
@@ -7623,9 +9399,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7623
9399
  }
7624
9400
  return c.json({ started: true, component, env });
7625
9401
  });
7626
- const rel = relative3(process.cwd(), webRoot) || ".";
9402
+ const rel = relative4(process.cwd(), webRoot) || ".";
7627
9403
  app.use("/*", serveStatic({ root: rel }));
7628
- app.get("/", serveStatic({ path: join16(rel, "index.html") }));
9404
+ app.get("/", serveStatic({ path: join18(rel, "index.html") }));
7629
9405
  return app;
7630
9406
  }
7631
9407
  async function bootLocalEmulators(dir, who) {
@@ -7654,7 +9430,7 @@ async function bootLocalEmulators(dir, who) {
7654
9430
  async function startServer(cfg) {
7655
9431
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
7656
9432
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
7657
- if (existsSync13(join16(dir, "chant.config.ts"))) addRecent(dir);
9433
+ if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
7658
9434
  }
7659
9435
  const broadcaster = new Broadcaster();
7660
9436
  const frames = new FrameBuffer();
@@ -7670,6 +9446,10 @@ async function startServer(cfg) {
7670
9446
  broadcaster.emit("changed", memberDir ?? "");
7671
9447
  void capture2();
7672
9448
  };
9449
+ const onMemberSourceChange = (memberDir) => {
9450
+ invalidateMember(memberDir);
9451
+ onEstateChange(memberDir);
9452
+ };
7673
9453
  const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename(dir)}]` : "";
7674
9454
  const onPollDrift = (dir, movedLexicons) => {
7675
9455
  onEstateChange(dir);
@@ -7699,7 +9479,7 @@ async function startServer(cfg) {
7699
9479
  };
7700
9480
  const carve = !!cfg.carveReport;
7701
9481
  let stopWatch = carve ? () => {
7702
- } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onEstateChange);
9482
+ } : watchSources(cfg.projectDirs ?? [cfg.projectDir], onMemberSourceChange);
7703
9483
  let stopPoll = !carve && cfg.env && cfg.pollSecs ? startDriftPoll({
7704
9484
  intervalMs: cfg.pollSecs * 1e3,
7705
9485
  // Per-member queries, swept sequentially inside the poll (#297): a
@@ -7716,7 +9496,7 @@ async function startServer(cfg) {
7716
9496
  };
7717
9497
  cfg.onProjectSwitch = (dir) => {
7718
9498
  stopWatch();
7719
- stopWatch = watchSources(cfg.projectDirs ?? [dir], onEstateChange);
9499
+ stopWatch = watchSources(cfg.projectDirs ?? [dir], onMemberSourceChange);
7720
9500
  stopPoll();
7721
9501
  stopPoll = () => {
7722
9502
  };
@@ -7789,12 +9569,12 @@ async function startServer(cfg) {
7789
9569
  }
7790
9570
 
7791
9571
  // src/export.ts
7792
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
7793
- import { join as join17, dirname as dirname5, basename as basename2 } from "node:path";
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";
7794
9574
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7795
- var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
9575
+ var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
7796
9576
  function canonicalKey(path, params) {
7797
- 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";
7798
9578
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial"))).map((k) => `${k}=${params.get(k)}`).join("&");
7799
9579
  return q ? `${path}?${q}` : path;
7800
9580
  }
@@ -7808,6 +9588,7 @@ function captureKeys(axes) {
7808
9588
  add("/api/project", {});
7809
9589
  add("/api/substrates", {});
7810
9590
  add("/api/ops", {});
9591
+ if (axes.ops) add("/api/graph", { ops: "1" });
7811
9592
  const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
7812
9593
  const envs = ["", ...axes.environments];
7813
9594
  for (const env of envs) {
@@ -7838,7 +9619,7 @@ function captureKeys(axes) {
7838
9619
  return [...keys];
7839
9620
  }
7840
9621
  function webDir() {
7841
- return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
9622
+ return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
7842
9623
  }
7843
9624
  function workerName(project, override) {
7844
9625
  const raw = override ?? `behold-${basename2(project)}`;
@@ -7848,8 +9629,8 @@ function workerName(project, override) {
7848
9629
  async function runExport(cfg, outDir, opts = {}) {
7849
9630
  const app = createApp({ ...cfg, layoutWrites: false });
7850
9631
  const proj = await (await app.request("/api/project")).json();
7851
- const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
7852
- 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");
7853
9634
  mkdirSync4(snapDir, { recursive: true });
7854
9635
  const keyToFile = {};
7855
9636
  let ok = 0;
@@ -7858,7 +9639,7 @@ async function runExport(cfg, outDir, opts = {}) {
7858
9639
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
7859
9640
  const body = await res.text();
7860
9641
  const file = slug2(key);
7861
- writeFileSync4(join17(snapDir, file), body);
9642
+ writeFileSync4(join19(snapDir, file), body);
7862
9643
  keyToFile[key] = `snapshots/${file}`;
7863
9644
  if (res.ok) ok++;
7864
9645
  else failed++;
@@ -7870,21 +9651,21 @@ async function runExport(cfg, outDir, opts = {}) {
7870
9651
  axes,
7871
9652
  keyToFile
7872
9653
  };
7873
- writeFileSync4(join17(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7874
- 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(
7875
9656
  /<\/head>/i,
7876
9657
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
7877
9658
  </head>`
7878
9659
  );
7879
- writeFileSync4(join17(outDir, "index.html"), html);
7880
- for (const f of readdirSync5(webDir())) {
9660
+ writeFileSync4(join19(outDir, "index.html"), html);
9661
+ for (const f of readdirSync7(webDir())) {
7881
9662
  if (f === "index.html") continue;
7882
- copyFileSync(join17(webDir(), f), join17(outDir, f));
9663
+ copyFileSync(join19(webDir(), f), join19(outDir, f));
7883
9664
  }
7884
- writeFileSync4(join17(outDir, "README.md"), BUNDLE_README);
9665
+ writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
7885
9666
  const name = workerName(cfg.projectDir, opts.name);
7886
9667
  writeFileSync4(
7887
- join17(outDir, "wrangler.jsonc"),
9668
+ join19(outDir, "wrangler.jsonc"),
7888
9669
  JSON.stringify(
7889
9670
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
7890
9671
  null,
@@ -7932,7 +9713,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
7932
9713
  `;
7933
9714
 
7934
9715
  // src/doctor.ts
7935
- import { relative as relative4, resolve as resolve6 } from "node:path";
9716
+ import { relative as relative5, resolve as resolve7 } from "node:path";
7936
9717
  var list2 = (xs) => xs.join(", ");
7937
9718
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
7938
9719
  function summarize(xs, max = 4) {
@@ -7940,7 +9721,7 @@ function summarize(xs, max = 4) {
7940
9721
  }
7941
9722
  function labelFor(root, target, estate) {
7942
9723
  if (!estate) return "";
7943
- const rel = relative4(root, target);
9724
+ const rel = relative5(root, target);
7944
9725
  return `${rel || "."}: `;
7945
9726
  }
7946
9727
  function chantCheck(root, targets, estate) {
@@ -7953,7 +9734,7 @@ function chantCheck(root, targets, estate) {
7953
9734
  name: "chant",
7954
9735
  status: "fail",
7955
9736
  detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
7956
- fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative4(root, target) || target))}`
9737
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
7957
9738
  };
7958
9739
  }
7959
9740
  const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
@@ -8081,7 +9862,7 @@ function opsCheck(root, ops, chantSource, estate) {
8081
9862
  return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
8082
9863
  }
8083
9864
  async function diagnose(dir, probes = {}) {
8084
- const root = resolve6(dir);
9865
+ const root = resolve7(dir);
8085
9866
  const shape = detectProjectShape(root);
8086
9867
  const behold = beholdVersion();
8087
9868
  if (shape.kind === "none") {
@@ -8101,16 +9882,16 @@ async function diagnose(dir, probes = {}) {
8101
9882
  };
8102
9883
  }
8103
9884
  const estate = shape.kind === "estate";
8104
- const members = (shape.members ?? []).map((m) => resolve6(root, m));
9885
+ const members = (shape.members ?? []).map((m) => resolve7(root, m));
8105
9886
  const targets = estate ? members : [root];
8106
9887
  const primary = targets[0];
8107
9888
  const projectCheck = estate ? {
8108
9889
  name: "project",
8109
9890
  status: "pass",
8110
9891
  detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
8111
- members.map((m) => relative4(root, m))
9892
+ members.map((m) => relative5(root, m))
8112
9893
  )}`
8113
- } : { name: "project", status: "pass", detail: `chant project (${relative4(root, shape.configFile)})` };
9894
+ } : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
8114
9895
  const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
8115
9896
  const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
8116
9897
  const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
@@ -8335,7 +10116,7 @@ ${USAGE}`);
8335
10116
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
8336
10117
  process.exit(2);
8337
10118
  }
8338
- const dirs = projectDirs.map((d) => resolve7(d));
10119
+ const dirs = projectDirs.map((d) => resolve8(d));
8339
10120
  for (const d of dirs) warnIfNotChantProject(d);
8340
10121
  await startServer({
8341
10122
  projectDir: dirs[0],
@@ -8354,7 +10135,7 @@ function warnIfNotChantProject(dir) {
8354
10135
  if (shape.kind === "estate") {
8355
10136
  process.stderr.write(
8356
10137
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
8357
- Serve its members composed: behold serve ${shape.members.map((m) => join18(dir, m)).join(" ")}
10138
+ Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
8358
10139
  `
8359
10140
  );
8360
10141
  return;
@@ -8389,8 +10170,8 @@ async function runCarve(rest) {
8389
10170
  );
8390
10171
  process.exit(2);
8391
10172
  }
8392
- const reportPath = resolve7(fileArg);
8393
- const parsed = readCarveReport(reportPath, (p) => readFileSync15(p, "utf8"));
10173
+ const reportPath = resolve8(fileArg);
10174
+ const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
8394
10175
  if (!parsed.ok) {
8395
10176
  process.stderr.write(`behold carve: ${parsed.refusal.error}
8396
10177
  ${parsed.refusal.remedy}
@@ -8401,7 +10182,7 @@ async function runCarve(rest) {
8401
10182
  `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
8402
10183
  `
8403
10184
  );
8404
- await startServer({ projectDir: dirname6(reportPath), carveReport: reportPath, port });
10185
+ await startServer({ projectDir: dirname7(reportPath), carveReport: reportPath, port });
8405
10186
  }
8406
10187
  async function runDoctor(rest) {
8407
10188
  let json = false;
@@ -8417,8 +10198,8 @@ async function runDoctor(rest) {
8417
10198
  }
8418
10199
  }
8419
10200
  const dir = dirArg ?? ".";
8420
- if (!existsSync14(resolve7(dir))) {
8421
- 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)}
8422
10203
  `);
8423
10204
  process.exit(2);
8424
10205
  }
@@ -8427,7 +10208,7 @@ async function runDoctor(rest) {
8427
10208
  if (!report.ok) process.exitCode = 1;
8428
10209
  }
8429
10210
  async function runDemo(rest) {
8430
- const pkgRoot2 = join18(dirname6(fileURLToPath5(import.meta.url)), "..");
10211
+ const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
8431
10212
  const registry = loadDemoRegistry(pkgRoot2);
8432
10213
  let port = 4600;
8433
10214
  let live = false;
@@ -8481,7 +10262,7 @@ async function runDemo(rest) {
8481
10262
  `);
8482
10263
  process.exit(2);
8483
10264
  }
8484
- const target = dirArg ? resolve7(dirArg) : demoTargetDir(entry);
10265
+ const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
8485
10266
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
8486
10267
  if (!loaded.ok) {
8487
10268
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -8512,7 +10293,7 @@ function spawnStep(cmd, args, cwd) {
8512
10293
  });
8513
10294
  }
8514
10295
  async function serveCarveDemo(target, carve, port, live = false) {
8515
- const at = (rel) => resolve7(target, rel);
10296
+ const at = (rel) => resolve8(target, rel);
8516
10297
  const project = at(carve.project);
8517
10298
  const from = at(carve.from);
8518
10299
  const state = carve.state ? at(carve.state) : void 0;
@@ -8552,7 +10333,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8552
10333
  };
8553
10334
  process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
8554
10335
  }
8555
- if (existsSync14(join18(project, "package.json")) && !existsSync14(join18(project, "node_modules"))) {
10336
+ if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
8556
10337
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
8557
10338
  `);
8558
10339
  const code = await spawnStep("npm", ["install"], project);
@@ -8563,7 +10344,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
8563
10344
  }
8564
10345
  }
8565
10346
  let degraded;
8566
- if (!existsSync14(join18(target, "node_modules", "@cdktf", "hcl2json"))) {
10347
+ if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
8567
10348
  process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
8568
10349
  const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
8569
10350
  if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
@@ -8632,7 +10413,7 @@ async function runPreview(rest) {
8632
10413
  process.stderr.write("behold preview: --port must be a number\n");
8633
10414
  process.exit(2);
8634
10415
  }
8635
- const projectDir = resolve7(dirArg ?? process.cwd());
10416
+ const projectDir = resolve8(dirArg ?? process.cwd());
8636
10417
  if (!existsSync14(projectDir)) {
8637
10418
  process.stderr.write(`behold preview: project not found at ${projectDir}
8638
10419
  `);
@@ -8653,21 +10434,21 @@ async function runPreview(rest) {
8653
10434
  await startServer({ projectDir, port, env: "local", previewMode: true });
8654
10435
  }
8655
10436
  async function runExportCmd(rest) {
8656
- let outDir = resolve7("behold-export");
10437
+ let outDir = resolve8("behold-export");
8657
10438
  let env;
8658
10439
  let name;
8659
10440
  let dirArg;
8660
10441
  let emulator = false;
8661
10442
  for (let i = 0; i < rest.length; i++) {
8662
10443
  const a = rest[i];
8663
- if (a === "--out") outDir = resolve7(rest[++i]);
10444
+ if (a === "--out") outDir = resolve8(rest[++i]);
8664
10445
  else if (a === "--env") env = rest[++i];
8665
10446
  else if (a === "--name") name = rest[++i];
8666
10447
  else if (a === "--emulator") emulator = true;
8667
10448
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
8668
10449
  else if (!a.startsWith("-")) dirArg = a;
8669
10450
  }
8670
- const projectDir = resolve7(dirArg ?? process.cwd());
10451
+ const projectDir = resolve8(dirArg ?? process.cwd());
8671
10452
  if (emulator) {
8672
10453
  injectEmulatorEnv(env);
8673
10454
  env ??= "local";