@intentius/behold 0.15.0 → 0.16.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 resolve9(selector, signal) {
719
+ async function resolve10(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 resolve9(selector, signal);
723
+ const info = await resolve10(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 resolve9({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
735
+ const info = await resolve10({ 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
  }
@@ -872,7 +872,7 @@ async function createK8sClient(options = {}) {
872
872
  return {
873
873
  provenance,
874
874
  defaultNamespace,
875
- resolve: resolve9,
875
+ resolve: resolve10,
876
876
  read,
877
877
  pathFor,
878
878
  readIfPresent,
@@ -1065,7 +1065,7 @@ var init_src = __esm({
1065
1065
  });
1066
1066
 
1067
1067
  // src/cli.ts
1068
- import { resolve as resolve8, dirname as dirname7, join as join20 } from "node:path";
1068
+ import { resolve as resolve9, dirname as dirname7, join as join21 } from "node:path";
1069
1069
  import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync16, rmSync as rmSync2 } from "node:fs";
1070
1070
  import { fileURLToPath as fileURLToPath5 } from "node:url";
1071
1071
  import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
@@ -1148,7 +1148,7 @@ import { fileURLToPath as fileURLToPath3 } from "node:url";
1148
1148
  import { execFile as execFile3 } from "node:child_process";
1149
1149
  import { promisify as promisify3 } from "node:util";
1150
1150
  import { existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
1151
- import { basename, dirname as dirname5, join as join18, relative as relative4, resolve as resolve6, sep as sep4 } from "node:path";
1151
+ import { basename, dirname as dirname5, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "node:path";
1152
1152
 
1153
1153
  // src/recents.ts
1154
1154
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -1182,10 +1182,10 @@ function addRecent(dir, now = () => (/* @__PURE__ */ new Date()).toISOString())
1182
1182
 
1183
1183
  // src/chant.ts
1184
1184
  import { spawn } from "node:child_process";
1185
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1185
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
1186
1186
  import { createRequire } from "node:module";
1187
1187
  import { fileURLToPath } from "node:url";
1188
- import { dirname as dirname2, join as join3, resolve } from "node:path";
1188
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "node:path";
1189
1189
 
1190
1190
  // src/project.ts
1191
1191
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
@@ -1305,6 +1305,30 @@ function readTiers(cfg) {
1305
1305
  if (!values.length) return void 0;
1306
1306
  return { envVar: tiers.envVar, values };
1307
1307
  }
1308
+ var EXECUTOR_FORGES = ["github", "gitlab", "forgejo"];
1309
+ function readExecutor(cfg) {
1310
+ const raw = cfg?.executor;
1311
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
1312
+ const out = {};
1313
+ for (const [env, value] of Object.entries(raw)) {
1314
+ if (!env) continue;
1315
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1316
+ out[env] = { invalid: `executor.${env} must be {forge, workflow}, got ${JSON.stringify(value)}` };
1317
+ continue;
1318
+ }
1319
+ const v = value;
1320
+ if (typeof v.forge !== "string" || !EXECUTOR_FORGES.includes(v.forge)) {
1321
+ out[env] = { invalid: `executor.${env}.forge must be one of ${EXECUTOR_FORGES.join(", ")}, got ${JSON.stringify(v.forge)}` };
1322
+ continue;
1323
+ }
1324
+ if (typeof v.workflow !== "string" || !/^[^/\\]+\.ya?ml$/.test(v.workflow)) {
1325
+ out[env] = { invalid: `executor.${env}.workflow must name a file in .github/workflows (e.g. deploy-${env}.yml), got ${JSON.stringify(v.workflow)}` };
1326
+ continue;
1327
+ }
1328
+ out[env] = { forge: v.forge, workflow: v.workflow };
1329
+ }
1330
+ return Object.keys(out).length ? out : void 0;
1331
+ }
1308
1332
  function loadBeholdConfig(projectDir) {
1309
1333
  const path = join2(projectDir, BEHOLD_CONFIG_NAME);
1310
1334
  if (!existsSync2(path)) return {};
@@ -1312,7 +1336,8 @@ function loadBeholdConfig(projectDir) {
1312
1336
  const raw = JSON.parse(readFileSync2(path, "utf8"));
1313
1337
  const tiers = readTiers(raw);
1314
1338
  const members = Array.isArray(raw.members) ? raw.members.filter((m) => typeof m === "string" && !!m) : [];
1315
- return { ...tiers ? { tiers } : {}, ...members.length ? { members } : {} };
1339
+ const executor = readExecutor(raw);
1340
+ return { ...tiers ? { tiers } : {}, ...members.length ? { members } : {}, ...executor ? { executor } : {} };
1316
1341
  } catch {
1317
1342
  return {};
1318
1343
  }
@@ -1664,6 +1689,309 @@ function unescapeDoubleQuoted(body) {
1664
1689
  return out;
1665
1690
  }
1666
1691
 
1692
+ // src/carve-manifest.ts
1693
+ import { readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
1694
+ import { dirname as dirname2, isAbsolute, join as join3, relative, resolve } from "node:path";
1695
+ var CARVE_MANIFEST_SUFFIX = ".carve.json";
1696
+ 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.";
1697
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1698
+ var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
1699
+ function parseCarveManifest(value) {
1700
+ if (!isRecord(value)) return null;
1701
+ if (value.version !== 1) return null;
1702
+ if (typeof value.target !== "string" || !value.target) return null;
1703
+ return value;
1704
+ }
1705
+ function readCarveManifest(path, readFile = (p) => readFileSync3(p, "utf8")) {
1706
+ let text;
1707
+ try {
1708
+ text = readFile(path);
1709
+ } catch {
1710
+ return null;
1711
+ }
1712
+ try {
1713
+ return parseCarveManifest(JSON.parse(text));
1714
+ } catch {
1715
+ return null;
1716
+ }
1717
+ }
1718
+ function carveStageOf(m) {
1719
+ if (m.apply) return "applied";
1720
+ if (m.bridge) return "bridged";
1721
+ return "emitted";
1722
+ }
1723
+ function carveWordFor(stage) {
1724
+ switch (stage) {
1725
+ case "applied":
1726
+ return "carved \u2192 chant";
1727
+ case "bridged":
1728
+ return "bridged \u2014 apply is yours";
1729
+ default:
1730
+ return "emitted \u2014 not bridged";
1731
+ }
1732
+ }
1733
+ function carveToneFor(stage) {
1734
+ return stage === "applied" ? "good" : "accent";
1735
+ }
1736
+ function carveStageNote(state) {
1737
+ switch (state.stage) {
1738
+ case "applied":
1739
+ 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.";
1740
+ case "bridged":
1741
+ return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
1742
+ default:
1743
+ return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
1744
+ }
1745
+ }
1746
+ function carveStateOf(m, path) {
1747
+ const stage = carveStageOf(m);
1748
+ const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
1749
+ return {
1750
+ target: m.target,
1751
+ ...m.tfType ? { tfType: m.tfType } : {},
1752
+ stage,
1753
+ graduated: stage === "applied",
1754
+ // The last stamped step wins — that is the age of the state, not the age
1755
+ // of the carve.
1756
+ ...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
1757
+ files: strings(m.emit?.files),
1758
+ sourceFiles: strings(m.emit?.files).map((f) => isAbsolute(f) ? f : resolve(dirname2(path), f)),
1759
+ excised: strings(m.bridge?.excised),
1760
+ deferredInputs: deferred,
1761
+ ...m.apply ? {
1762
+ ownership: {
1763
+ ...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
1764
+ ...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
1765
+ ...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
1766
+ }
1767
+ } : {},
1768
+ ...m.from ? { from: m.from } : {},
1769
+ outDir: dirname2(path),
1770
+ path
1771
+ };
1772
+ }
1773
+ var nodeCarveIo = {
1774
+ readdir: (dir) => readdirSync(dir),
1775
+ isDirectory: (p) => {
1776
+ try {
1777
+ return statSync(p).isDirectory();
1778
+ } catch {
1779
+ return false;
1780
+ }
1781
+ },
1782
+ readFile: (p) => readFileSync3(p, "utf8")
1783
+ };
1784
+ var MANIFEST_SCAN_DEPTH = 2;
1785
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
1786
+ function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
1787
+ const out = [];
1788
+ const walk = (d, level) => {
1789
+ let entries;
1790
+ try {
1791
+ entries = io.readdir(d).sort();
1792
+ } catch {
1793
+ return;
1794
+ }
1795
+ for (const name of entries) {
1796
+ const full = join3(d, name);
1797
+ if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
1798
+ out.push(full);
1799
+ continue;
1800
+ }
1801
+ if (level >= depth) continue;
1802
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
1803
+ if (io.isDirectory(full)) walk(full, level + 1);
1804
+ }
1805
+ };
1806
+ walk(dir, 0);
1807
+ return out;
1808
+ }
1809
+ function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
1810
+ const byTarget = /* @__PURE__ */ new Map();
1811
+ for (const dir of dirs) mergeCarveStates(byTarget, listCarveManifests(dir, io, depth), io);
1812
+ return byTarget;
1813
+ }
1814
+ function mergeCarveStates(byTarget, paths, io) {
1815
+ const rank = { emitted: 0, bridged: 1, applied: 2 };
1816
+ for (const path of paths) {
1817
+ const manifest = readCarveManifest(path, io.readFile);
1818
+ if (!manifest) continue;
1819
+ const state = carveStateOf(manifest, path);
1820
+ const held = byTarget.get(state.target);
1821
+ if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
1822
+ }
1823
+ }
1824
+ var CARVE_STATUS_FLOOR = "0.54.0";
1825
+ function carveStatusArgs(from) {
1826
+ return ["carve", "status", "--from", from, "--json"];
1827
+ }
1828
+ async function discoverCarveStates(dirs, read, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
1829
+ const byTarget = /* @__PURE__ */ new Map();
1830
+ for (const dir of dirs) {
1831
+ const status = await read(dir).catch(() => void 0);
1832
+ const paths = status?.carves ? status.carves.map((row) => resolve(status.from ?? dir, row.path)) : listCarveManifests(dir, io, depth);
1833
+ mergeCarveStates(byTarget, paths, io);
1834
+ }
1835
+ return byTarget;
1836
+ }
1837
+ function joinCarvedSources(ir, states, memberDir) {
1838
+ const byFile = /* @__PURE__ */ new Map();
1839
+ for (const s of states) for (const f of s.sourceFiles) byFile.set(resolve(f), s);
1840
+ if (byFile.size === 0) return { ir, joined: 0 };
1841
+ let joined = 0;
1842
+ const nodes = ir.nodes.map((n) => {
1843
+ const file = n.sourceLoc?.file;
1844
+ if (!file) return n;
1845
+ const abs = resolve(memberDir, file);
1846
+ const state = byFile.get(abs);
1847
+ if (!state) return n;
1848
+ joined++;
1849
+ const carve = {
1850
+ target: state.target,
1851
+ ...state.tfType ? { tfType: state.tfType } : {},
1852
+ stage: state.stage,
1853
+ graduated: state.graduated,
1854
+ ...state.at ? { at: state.at } : {},
1855
+ ...state.from ? { from: state.from } : {},
1856
+ file: relative(memberDir, abs).split("\\").join("/")
1857
+ };
1858
+ return { ...n, attrs: { ...n.attrs, carved: state.target, _carve: carve } };
1859
+ });
1860
+ return { ir: joined ? { ...ir, nodes } : ir, joined };
1861
+ }
1862
+ function emittedNodesFor(ir, states, memberDir) {
1863
+ const out = /* @__PURE__ */ new Map();
1864
+ if (!ir) return out;
1865
+ for (const n of joinCarvedSources(ir, states, memberDir).ir.nodes) {
1866
+ const carve = n.attrs._carve;
1867
+ if (carve && !out.has(carve.target)) out.set(carve.target, n);
1868
+ }
1869
+ return out;
1870
+ }
1871
+ function carveProgress(states, total) {
1872
+ let applied = 0;
1873
+ let bridged = 0;
1874
+ let emitted = 0;
1875
+ for (const s of states) {
1876
+ if (s.stage === "applied") applied++;
1877
+ else if (s.stage === "bridged") bridged++;
1878
+ else emitted++;
1879
+ }
1880
+ const inFlight = bridged + emitted;
1881
+ const parts = [];
1882
+ if (emitted) parts.push(`${emitted} emitted`);
1883
+ if (bridged) parts.push(`${bridged} bridged`);
1884
+ return {
1885
+ applied,
1886
+ bridged,
1887
+ emitted,
1888
+ inFlight,
1889
+ total: typeof total === "number" ? total : null,
1890
+ label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
1891
+ detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
1892
+ };
1893
+ }
1894
+ function applyCommandFor(state, labels = {}) {
1895
+ const env = state.ownership?.env ?? "<env>";
1896
+ const stack = state.ownership?.stack ?? "<stack>";
1897
+ const from = labels.from ?? state.from ?? "<terraform-dir>";
1898
+ const out = labels.out ?? state.outDir;
1899
+ return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
1900
+ }
1901
+ function carveStatePayload(states, total, labels = () => ({})) {
1902
+ const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
1903
+ return {
1904
+ manifests: list3.length,
1905
+ progress: carveProgress(list3, total),
1906
+ states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
1907
+ apply: { human: true, note: APPLY_IS_HUMAN }
1908
+ };
1909
+ }
1910
+ function splitCarveState(tfIr, states, opts = {}) {
1911
+ if (states.size === 0) return { tf: tfIr, graduated: [] };
1912
+ const shorten = opts.shorten ?? ((p) => p);
1913
+ const emitted = opts.emitted ?? /* @__PURE__ */ new Map();
1914
+ const declaredAttrs = (n) => Object.fromEntries(Object.entries(n.attrs ?? {}).filter(([k]) => !k.startsWith("_") && k !== "carved"));
1915
+ const source = (files) => files.map(shorten).join(", ");
1916
+ const gone = /* @__PURE__ */ new Set();
1917
+ const graduated = [];
1918
+ const nodes = [];
1919
+ for (const node of tfIr.nodes) {
1920
+ const state = states.get(node.id);
1921
+ if (!state) {
1922
+ nodes.push(node);
1923
+ continue;
1924
+ }
1925
+ const became = emitted.get(node.id);
1926
+ if (state.graduated) {
1927
+ gone.add(node.id);
1928
+ graduated.push({
1929
+ ...node,
1930
+ ...became ? { kind: became.kind, lexicon: became.lexicon, ...became.sourceLoc ? { sourceLoc: became.sourceLoc } : {} } : {},
1931
+ attrs: {
1932
+ ...node.attrs,
1933
+ ...became ? { ...declaredAttrs(became), chantEntity: `${became.kind} ${became.id}` } : {},
1934
+ _status: "good",
1935
+ carve: carveWordFor(state.stage),
1936
+ carveState: state.stage,
1937
+ ...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
1938
+ ...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
1939
+ ...state.at ? { carvedAt: state.at } : {},
1940
+ ...state.files.length ? { chantSource: source(state.files) } : {}
1941
+ }
1942
+ });
1943
+ continue;
1944
+ }
1945
+ nodes.push({
1946
+ ...node,
1947
+ attrs: {
1948
+ ...node.attrs,
1949
+ _status: carveToneFor(state.stage),
1950
+ carve: carveWordFor(state.stage),
1951
+ carveState: state.stage,
1952
+ ...became ? { chantEntity: `${became.kind} ${became.id}` } : {},
1953
+ ...state.at ? { carvedAt: state.at } : {},
1954
+ ...state.files.length ? { chantSource: source(state.files) } : {},
1955
+ ...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
1956
+ ...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
1957
+ }
1958
+ });
1959
+ }
1960
+ const byStack = {};
1961
+ for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
1962
+ const left = members.filter((id) => !gone.has(id));
1963
+ if (left.length) byStack[band] = left;
1964
+ }
1965
+ return {
1966
+ tf: {
1967
+ nodes,
1968
+ edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
1969
+ groups: { ...tfIr.groups, byStack }
1970
+ },
1971
+ graduated
1972
+ };
1973
+ }
1974
+ var GRADUATED_BAND = "carved \u2192 chant";
1975
+ function bandGraduated(tf, graduated) {
1976
+ if (graduated.length === 0) return tf;
1977
+ return {
1978
+ nodes: [...graduated, ...tf.nodes],
1979
+ edges: tf.edges,
1980
+ groups: {
1981
+ ...tf.groups,
1982
+ byStack: {
1983
+ [GRADUATED_BAND]: graduated.map((n) => n.id),
1984
+ ...tf.groups?.byStack ?? {}
1985
+ }
1986
+ }
1987
+ };
1988
+ }
1989
+ function carveStateNote(progress, states) {
1990
+ if (states.size === 0) return "";
1991
+ const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
1992
+ return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
1993
+ }
1994
+
1667
1995
  // src/chant.ts
1668
1996
  function envOverridesFor(opts) {
1669
1997
  const overrides = {};
@@ -1694,14 +2022,14 @@ function packageManifestFrom(req, name) {
1694
2022
  } catch {
1695
2023
  return void 0;
1696
2024
  }
1697
- let dir = dirname2(entry);
2025
+ let dir = dirname3(entry);
1698
2026
  for (; ; ) {
1699
2027
  try {
1700
- const pkg = createRequire(import.meta.url)(join3(dir, "package.json"));
2028
+ const pkg = createRequire(import.meta.url)(join4(dir, "package.json"));
1701
2029
  if (pkg.name === name) return { root: dir, manifest: pkg };
1702
2030
  } catch {
1703
2031
  }
1704
- const parent = dirname2(dir);
2032
+ const parent = dirname3(dir);
1705
2033
  if (parent === dir) break;
1706
2034
  dir = parent;
1707
2035
  }
@@ -1711,10 +2039,10 @@ function chantPackageFrom(req) {
1711
2039
  const found = packageManifestFrom(req, "@intentius/chant");
1712
2040
  if (!found) return void 0;
1713
2041
  const { root, manifest } = found;
1714
- return { root, bin: join3(root, manifest.bin?.chant ?? "bin/chant"), ...manifest.version ? { version: manifest.version } : {} };
2042
+ return { root, bin: join4(root, manifest.bin?.chant ?? "bin/chant"), ...manifest.version ? { version: manifest.version } : {} };
1715
2043
  }
1716
2044
  function resolveLexicons(projectDir, lexicons) {
1717
- const req = createRequire(join3(resolve(projectDir), "noop.js"));
2045
+ const req = createRequire(join4(resolve2(projectDir), "noop.js"));
1718
2046
  return lexicons.map((lexicon) => {
1719
2047
  const pkg = `@intentius/chant-lexicon-${lexicon}`;
1720
2048
  const found = packageManifestFrom(req, pkg);
@@ -1724,7 +2052,7 @@ function resolveLexicons(projectDir, lexicons) {
1724
2052
  function resolveChant(projectDir) {
1725
2053
  const own = chantPackageFrom(createRequire(import.meta.url));
1726
2054
  if (projectDir) {
1727
- const fromProject = chantPackageFrom(createRequire(join3(resolve(projectDir), "noop.js")));
2055
+ const fromProject = chantPackageFrom(createRequire(join4(resolve2(projectDir), "noop.js")));
1728
2056
  if (fromProject) return { source: fromProject.root === own?.root ? "behold" : "project", ...fromProject };
1729
2057
  }
1730
2058
  if (own) return { source: "behold", ...own };
@@ -1732,8 +2060,8 @@ function resolveChant(projectDir) {
1732
2060
  }
1733
2061
  function chantFloor() {
1734
2062
  try {
1735
- const here = dirname2(fileURLToPath(import.meta.url));
1736
- const pkg = JSON.parse(readFileSync3(join3(here, "..", "package.json"), "utf8"));
2063
+ const here = dirname3(fileURLToPath(import.meta.url));
2064
+ const pkg = JSON.parse(readFileSync4(join4(here, "..", "package.json"), "utf8"));
1737
2065
  return pkg.dependencies?.["@intentius/chant"]?.replace(/^[^0-9]*/, "") || void 0;
1738
2066
  } catch {
1739
2067
  return void 0;
@@ -1839,7 +2167,7 @@ function runCommandStream(cmd, args, cwd, onLine) {
1839
2167
  return { pid: proc.pid ?? -1, kill: () => proc.kill(), done };
1840
2168
  }
1841
2169
  function legacyGraphPath(projectDir) {
1842
- const src = join3(projectDir, "src");
2170
+ const src = join4(projectDir, "src");
1843
2171
  return existsSync3(src) ? src : projectDir;
1844
2172
  }
1845
2173
  async function graphPath(projectDir, opts = {}) {
@@ -1851,9 +2179,9 @@ async function graphPath(projectDir, opts = {}) {
1851
2179
  }
1852
2180
  if (info.stacks?.length) {
1853
2181
  const picked = opts.stack ? info.stacks.find((s) => s.name === opts.stack) : void 0;
1854
- return resolve(projectDir, (picked ?? info.stacks[0]).src);
2182
+ return resolve2(projectDir, (picked ?? info.stacks[0]).src);
1855
2183
  }
1856
- if (info.sourceDir) return resolve(projectDir, info.sourceDir);
2184
+ if (info.sourceDir) return resolve2(projectDir, info.sourceDir);
1857
2185
  return legacyGraphPath(projectDir);
1858
2186
  }
1859
2187
  function graphArgs(src, format, opts, components) {
@@ -1864,7 +2192,7 @@ async function graphIr(projectDir, opts = {}) {
1864
2192
  return runChantJson(graphArgs(src, "ir", opts, false), projectDir, envOverridesFor(opts));
1865
2193
  }
1866
2194
  async function clusterRootGraphIr(projectDir, opts = {}) {
1867
- if (!existsSync3(join3(projectDir, "cluster"))) return void 0;
2195
+ if (!existsSync3(join4(projectDir, "cluster"))) return void 0;
1868
2196
  const { live: _live, overlay: _overlay, env: _env, ...sourceOnly } = opts;
1869
2197
  try {
1870
2198
  return await runChantJson(graphArgs("cluster", "ir", sourceOnly, false), projectDir, envOverridesFor(sourceOnly));
@@ -1882,6 +2210,9 @@ function componentStatusArgs(env) {
1882
2210
  function componentStatus(projectDir, env, opts = {}) {
1883
2211
  return runChantJson(componentStatusArgs(env), projectDir, envOverridesFor(opts));
1884
2212
  }
2213
+ function ciWorkflowName(env) {
2214
+ return `chant-components-${env}`;
2215
+ }
1885
2216
  var CI_FORGES = ["gitlab", "github", "forgejo"];
1886
2217
  function ciForgeFor(lexicons) {
1887
2218
  return CI_FORGES.find((forge) => lexicons.includes(forge));
@@ -1899,7 +2230,7 @@ function parseCiPipeline(stdout) {
1899
2230
  const script = Array.isArray(props?.script) ? props.script.map(String) : [];
1900
2231
  return { ...j, script };
1901
2232
  });
1902
- return { stages: parsed.stages, jobs };
2233
+ return { stages: parsed.stages, jobs, ...typeof parsed.env === "string" && parsed.env ? { env: parsed.env } : {} };
1903
2234
  }
1904
2235
  function ciPipeline(projectDir, opts = {}, forge = "gitlab") {
1905
2236
  const args = ciPipelineArgs(opts, forge);
@@ -1920,6 +2251,9 @@ function lifecycleDiffArgs(env) {
1920
2251
  function lifecycleDiffLive(projectDir, env, opts = {}) {
1921
2252
  return runChantJson(lifecycleDiffArgs(env), projectDir, envOverridesFor(opts));
1922
2253
  }
2254
+ function carveStatus(from, projectDir) {
2255
+ return runChantJson(carveStatusArgs(from), projectDir);
2256
+ }
1923
2257
  function helmRendersArgs() {
1924
2258
  return ["helm", "renders", "--json"];
1925
2259
  }
@@ -1996,8 +2330,8 @@ function mergeClusterRoot(ir, clusterIr, running) {
1996
2330
  }
1997
2331
 
1998
2332
  // src/helm-releases.ts
1999
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
2000
- import { join as join4 } from "node:path";
2333
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
2334
+ import { join as join5 } from "node:path";
2001
2335
  var RELEASE_KEY = /^release\/([^/]+)\/(.+)$/;
2002
2336
  function declaredChartNames(ir) {
2003
2337
  const names = /* @__PURE__ */ new Set();
@@ -2017,15 +2351,15 @@ function matchesDeclaredChart(chart, declared) {
2017
2351
  }
2018
2352
  function discoverReleaseUnits(projectDir) {
2019
2353
  const owners = /* @__PURE__ */ new Map();
2020
- const roots = [join4(projectDir, "src"), join4(projectDir, "ops")];
2354
+ const roots = [join5(projectDir, "src"), join5(projectDir, "ops")];
2021
2355
  const files = [];
2022
2356
  const walk = (dir, depth) => {
2023
2357
  if (depth > 4 || !existsSync4(dir)) return;
2024
- for (const f of readdirSync(dir)) {
2025
- const p = join4(dir, f);
2358
+ for (const f of readdirSync2(dir)) {
2359
+ const p = join5(dir, f);
2026
2360
  let s;
2027
2361
  try {
2028
- s = statSync(p);
2362
+ s = statSync2(p);
2029
2363
  } catch {
2030
2364
  continue;
2031
2365
  }
@@ -2037,7 +2371,7 @@ function discoverReleaseUnits(projectDir) {
2037
2371
  for (const file of files) {
2038
2372
  let content;
2039
2373
  try {
2040
- content = readFileSync4(file, "utf8");
2374
+ content = readFileSync5(file, "utf8");
2041
2375
  } catch {
2042
2376
  continue;
2043
2377
  }
@@ -2090,8 +2424,8 @@ function synthesizeHelmReleases(ir, observed, owners) {
2090
2424
 
2091
2425
  // src/gh-run.ts
2092
2426
  import { spawn as spawn2 } from "node:child_process";
2093
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
2094
- import { join as join5 } from "node:path";
2427
+ import { readdirSync as readdirSync3, readFileSync as readFileSync6 } from "node:fs";
2428
+ import { join as join6 } from "node:path";
2095
2429
 
2096
2430
  // src/ci-run.ts
2097
2431
  function pipelineProgress(pipeline) {
@@ -2145,19 +2479,19 @@ function finishPipelineProgress(state, exitCode) {
2145
2479
  }
2146
2480
 
2147
2481
  // src/gh-run.ts
2148
- var defaultGhExec = (args) => new Promise((resolve9) => {
2482
+ var defaultGhExec = (args) => new Promise((resolve10) => {
2149
2483
  let out = "";
2150
2484
  let proc;
2151
2485
  try {
2152
2486
  proc = spawn2("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
2153
2487
  } catch {
2154
- resolve9({ code: 127, out: "" });
2488
+ resolve10({ code: 127, out: "" });
2155
2489
  return;
2156
2490
  }
2157
2491
  proc.stdout.on("data", (d) => out += d);
2158
2492
  proc.stderr.on("data", (d) => out += d);
2159
- proc.on("error", () => resolve9({ code: 127, out }));
2160
- proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
2493
+ proc.on("error", () => resolve10({ code: 127, out }));
2494
+ proc.on("close", (code) => resolve10({ code: code ?? 1, out }));
2161
2495
  });
2162
2496
  async function ghReady(exec = defaultGhExec) {
2163
2497
  const { code, out } = await exec(["auth", "status"]);
@@ -2176,31 +2510,49 @@ function parseWorkflow(file, text) {
2176
2510
  const jobs = d.jobs && typeof d.jobs === "object" && !Array.isArray(d.jobs) ? Object.keys(d.jobs) : [];
2177
2511
  const on = d.on ?? d[true];
2178
2512
  const dispatchable = on === "workflow_dispatch" || Array.isArray(on) && on.includes("workflow_dispatch") || !!on && typeof on === "object" && "workflow_dispatch" in on || /^\s{2,}workflow_dispatch\s*:?\s*$/m.test(text);
2179
- return { file, jobIds: jobs, dispatchable };
2513
+ return { file, jobIds: jobs, dispatchable, ...typeof d.name === "string" && d.name ? { name: d.name } : {} };
2180
2514
  }
2181
- function pickWorkflow(projectDir, pipeline) {
2182
- const dir = join5(projectDir, ".github", "workflows");
2515
+ function committedWorkflows(projectDir) {
2516
+ const dir = join6(projectDir, ".github", "workflows");
2183
2517
  let files;
2184
2518
  try {
2185
- files = readdirSync2(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2519
+ files = readdirSync3(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2186
2520
  } catch {
2187
- return void 0;
2521
+ return [];
2188
2522
  }
2189
- const wanted = new Set(pipeline.jobs.map((j) => j.jobName));
2190
- let best;
2523
+ const out = [];
2191
2524
  for (const f of files) {
2192
- let text;
2193
2525
  try {
2194
- text = readFileSync5(join5(dir, f), "utf8");
2526
+ out.push(parseWorkflow(f, readFileSync6(join6(dir, f), "utf8")));
2195
2527
  } catch {
2196
- continue;
2197
2528
  }
2198
- const info = parseWorkflow(f, text);
2199
- if (!info.dispatchable) continue;
2200
- const overlap = info.jobIds.filter((id) => wanted.has(id)).length;
2201
- if (overlap > 0 && (!best || overlap > best.overlap)) best = { info, overlap };
2202
2529
  }
2203
- return best?.info;
2530
+ return out;
2531
+ }
2532
+ function resolveWorkflow(projectDir, pipeline, designated) {
2533
+ const all = committedWorkflows(projectDir);
2534
+ if (designated) {
2535
+ const hit = all.find((w) => w.file === designated);
2536
+ if (!hit) return { reason: `the designated workflow .github/workflows/${designated} is not committed \u2014 Deploy is disabled for this env until it is (behold never falls back to a guess, or to running it here)` };
2537
+ if (!hit.dispatchable) return { reason: `the designated workflow ${designated} declares no workflow_dispatch \u2014 add it to its \`on:\` block; until then Deploy is disabled for this env` };
2538
+ return { workflow: hit };
2539
+ }
2540
+ const dispatchable = all.filter((w) => w.dispatchable);
2541
+ if (pipeline.env) {
2542
+ const named = dispatchable.filter((w) => w.name === ciWorkflowName(pipeline.env));
2543
+ if (named.length === 1) return { workflow: named[0] };
2544
+ if (named.length > 1) return { reason: `${named.length} committed workflows are named ${ciWorkflowName(pipeline.env)} (${named.map((w) => w.file).join(", ")}) \u2014 designate one in .behold.json's executor block` };
2545
+ }
2546
+ const wanted = new Set(pipeline.jobs.map((j) => j.jobName));
2547
+ const scored = dispatchable.map((w) => ({ w, overlap: w.jobIds.filter((id) => wanted.has(id)).length })).filter((x) => x.overlap > 0).sort((a, b) => b.overlap - a.overlap);
2548
+ if (scored.length === 0) {
2549
+ return { reason: "no committed workflow_dispatch workflow whose jobs match the generated pipeline \u2014 commit one (or add `workflow_dispatch:` to its `on:` block)" };
2550
+ }
2551
+ if (scored.length > 1 && scored[1].overlap === scored[0].overlap) {
2552
+ const tied = scored.filter((x) => x.overlap === scored[0].overlap).map((x) => x.w.file);
2553
+ return { reason: `${tied.length} committed workflows match the pipeline equally (${tied.join(", ")}) \u2014 the generated job ids are the same for every env, so behold cannot tell which one deploys ${pipeline.env ?? "this env"}; designate it in .behold.json's executor block` };
2554
+ }
2555
+ return { workflow: scored[0].w };
2204
2556
  }
2205
2557
  async function latestRunId(workflowFile, exec = defaultGhExec) {
2206
2558
  const { code, out } = await exec(["run", "list", "--workflow", workflowFile, "--limit", "1", "--json", "databaseId"]);
@@ -2234,6 +2586,7 @@ function runViewToProgress(pipeline, view) {
2234
2586
  return { ...w, status };
2235
2587
  })
2236
2588
  };
2589
+ if (view.url) state = { ...state, url: view.url };
2237
2590
  if (view.status === "completed") {
2238
2591
  return { ...state, status: view.conclusion === "success" ? "ok" : "failed" };
2239
2592
  }
@@ -2267,8 +2620,9 @@ async function dispatchAndFollow(workflow, pipeline, ref, deps) {
2267
2620
  return 1;
2268
2621
  }
2269
2622
  deps.onLine(`\u25CF run ${runId} started \u2014 following`);
2623
+ let urlSaid = false;
2270
2624
  for (; ; ) {
2271
- const { code, out } = await exec(["run", "view", String(runId), "--json", "status,conclusion,jobs"]);
2625
+ const { code, out } = await exec(["run", "view", String(runId), "--json", "status,conclusion,jobs,url"]);
2272
2626
  if (code === 0) {
2273
2627
  let view;
2274
2628
  try {
@@ -2277,6 +2631,10 @@ async function dispatchAndFollow(workflow, pipeline, ref, deps) {
2277
2631
  view = void 0;
2278
2632
  }
2279
2633
  if (view) {
2634
+ if (view.url && !urlSaid) {
2635
+ urlSaid = true;
2636
+ deps.onLine(`\u25CF ${view.url} \u2014 if the workflow's environment requires an approval, it is granted there, by a GitHub identity; behold holds none that could`);
2637
+ }
2280
2638
  deps.onProgress(runViewToProgress(pipeline, view));
2281
2639
  if (view.status === "completed") {
2282
2640
  deps.onLine(`\u25A0 run ${runId} ${view.conclusion ?? "completed"}`);
@@ -2313,15 +2671,15 @@ function joinCiProgress(ir, state) {
2313
2671
  // src/operator.ts
2314
2672
  var ANSI = /\x1b\[[0-9;]*m/g;
2315
2673
  var strip = (s) => s.replace(ANSI, "");
2316
- var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2674
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2317
2675
  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).";
2318
2676
  function readRow(value) {
2319
- if (!isRecord(value)) return null;
2677
+ if (!isRecord2(value)) return null;
2320
2678
  if (typeof value.op !== "string" || typeof value.env !== "string") return null;
2321
2679
  if (!Array.isArray(value.pendingGates)) return null;
2322
2680
  const gates = [];
2323
2681
  for (const g of value.pendingGates) {
2324
- if (!isRecord(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
2682
+ if (!isRecord2(g) || typeof g.rule !== "string" || typeof g.gate !== "string") return null;
2325
2683
  gates.push({
2326
2684
  rule: g.rule,
2327
2685
  gate: g.gate,
@@ -2333,9 +2691,9 @@ function readRow(value) {
2333
2691
  ...typeof g.url === "string" && g.url ? { url: g.url } : {}
2334
2692
  });
2335
2693
  }
2336
- const tick = isRecord(value.lastTick) ? value.lastTick : void 0;
2694
+ const tick = isRecord2(value.lastTick) ? value.lastTick : void 0;
2337
2695
  const usable = tick && typeof tick.timestamp === "string" && typeof tick.log === "string";
2338
- const lease = isRecord(value.lease) ? value.lease : void 0;
2696
+ const lease = isRecord2(value.lease) ? value.lease : void 0;
2339
2697
  return {
2340
2698
  op: value.op,
2341
2699
  env: value.env,
@@ -2433,7 +2791,7 @@ var CHANT = "chant";
2433
2791
  var CONVERGE_TICK = "converge-tick";
2434
2792
  var CRONJOB_KIND = "K8s::Batch::CronJob";
2435
2793
  var NAMESPACE_KIND = "K8s::Core::Namespace";
2436
- var rec = (v) => isRecord(v) ? v : void 0;
2794
+ var rec = (v) => isRecord2(v) ? v : void 0;
2437
2795
  var str = (v) => typeof v === "string" && v ? v : void 0;
2438
2796
  var labelsOf = (n) => rec(rec(n.attrs?.metadata)?.labels) ?? {};
2439
2797
  function operatorHomes(ir) {
@@ -2541,10 +2899,10 @@ function tickFreshness(at, now = Date.now()) {
2541
2899
  }
2542
2900
  var RECONCILIATIONS = /* @__PURE__ */ new Set(["reconciled", "unrecorded", "stale", "drifted", "unknown"]);
2543
2901
  function readVerdict(value) {
2544
- if (!isRecord(value)) return null;
2902
+ if (!isRecord2(value)) return null;
2545
2903
  if (typeof value.component !== "string" || !value.component) return null;
2546
2904
  if (typeof value.reconciliation !== "string" || !RECONCILIATIONS.has(value.reconciliation)) return null;
2547
- const unobserved = isRecord(value.unobserved) && typeof value.unobserved.reason === "string" ? value.unobserved : void 0;
2905
+ const unobserved = isRecord2(value.unobserved) && typeof value.unobserved.reason === "string" ? value.unobserved : void 0;
2548
2906
  return {
2549
2907
  component: value.component,
2550
2908
  reconciliation: value.reconciliation,
@@ -2655,12 +3013,12 @@ function operatorLogArgs(window) {
2655
3013
  ];
2656
3014
  }
2657
3015
  function readTickRecord(value) {
2658
- if (!isRecord(value)) return null;
3016
+ if (!isRecord2(value)) return null;
2659
3017
  if (typeof value.op !== "string" || typeof value.timestamp !== "string" || typeof value.log !== "string") return null;
2660
3018
  return value;
2661
3019
  }
2662
3020
  function readResolutionRecord(value) {
2663
- if (!isRecord(value)) return null;
3021
+ if (!isRecord2(value)) return null;
2664
3022
  const needed = ["op", "gate", "resolvedBy", "timestamp"];
2665
3023
  if (needed.some((k) => typeof value[k] !== "string")) return null;
2666
3024
  return value;
@@ -2721,10 +3079,10 @@ function readOperatorLog(run3) {
2721
3079
  remedy: "behold reads `{entries: [{kind, timestamp, record}], malformed: {converge, gates}}`."
2722
3080
  }
2723
3081
  });
2724
- if (!isRecord(parsed) || !Array.isArray(parsed.entries)) return unreadable("no `entries` array");
3082
+ if (!isRecord2(parsed) || !Array.isArray(parsed.entries)) return unreadable("no `entries` array");
2725
3083
  const entries = [];
2726
3084
  for (const value of parsed.entries) {
2727
- if (!isRecord(value) || typeof value.timestamp !== "string") return unreadable("an entry with no timestamp");
3085
+ if (!isRecord2(value) || typeof value.timestamp !== "string") return unreadable("an entry with no timestamp");
2728
3086
  if (value.kind === "tick") {
2729
3087
  const record = readTickRecord(value.record);
2730
3088
  if (!record) return unreadable("a tick entry with no `op`/`timestamp`/`log`");
@@ -2737,7 +3095,7 @@ function readOperatorLog(run3) {
2737
3095
  return unreadable(`an entry of an unknown kind "${String(value.kind)}"`);
2738
3096
  }
2739
3097
  }
2740
- const counts = isRecord(parsed.malformed) ? parsed.malformed : {};
3098
+ const counts = isRecord2(parsed.malformed) ? parsed.malformed : {};
2741
3099
  const count = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
2742
3100
  return { ok: true, entries, malformed: { converge: count(counts.converge), gates: count(counts.gates) } };
2743
3101
  }
@@ -3369,7 +3727,9 @@ function applyHelmArtifacts(ir, observed) {
3369
3727
  release: key.replace(/^release\//, ""),
3370
3728
  ...o.status ? { status: o.status } : {},
3371
3729
  ...typeof o.attributes?.revision === "string" ? { revision: o.attributes.revision } : {},
3372
- ...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {}
3730
+ ...typeof o.attributes?.chart === "string" ? { chart: o.attributes.chart } : {},
3731
+ ...typeof o.attributes?.inputDigest === "string" ? { inputDigest: o.attributes.inputDigest } : {},
3732
+ ...typeof o.attributes?.contentDigest === "string" ? { contentDigest: o.attributes.contentDigest } : {}
3373
3733
  };
3374
3734
  attrs._artifact = match;
3375
3735
  attrs._status = o.status === "deployed" ? "good" : "warn";
@@ -3378,37 +3738,35 @@ function applyHelmArtifacts(ir, observed) {
3378
3738
  return { charts, installed };
3379
3739
  }
3380
3740
 
3381
- // src/estate.ts
3382
- import { statSync as statSync3 } from "node:fs";
3383
- import { availableParallelism } from "node:os";
3384
- import { join as join7, resolve as resolve3, sep } from "node:path";
3385
- import { composeStacks, shortStackNames } from "@intentius/pinhole";
3741
+ // src/carve-discovery.ts
3742
+ import { readdirSync as readdirSync5, statSync as statSync4 } from "node:fs";
3743
+ import { join as join8 } from "node:path";
3386
3744
 
3387
3745
  // src/member-ir.ts
3388
3746
  import { createHash } from "node:crypto";
3389
- import { readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
3390
- import { join as join6, relative, resolve as resolve2 } from "node:path";
3747
+ import { readdirSync as readdirSync4, statSync as statSync3 } from "node:fs";
3748
+ import { join as join7, relative as relative2, resolve as resolve3 } from "node:path";
3391
3749
  var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
3392
3750
  function memberIrCacheSize(env = process.env) {
3393
3751
  const override = Number.parseInt(env.BEHOLD_ESTATE_IR_CACHE ?? "", 10);
3394
3752
  return Number.isInteger(override) && override >= 0 ? override : 64;
3395
3753
  }
3396
3754
  function memberSourceStamp(dir) {
3397
- const root = resolve2(dir);
3755
+ const root = resolve3(dir);
3398
3756
  const h = createHash("sha1");
3399
3757
  let any = false;
3400
3758
  const walk = (at) => {
3401
- const entries = readdirSync3(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
3759
+ const entries = readdirSync4(at, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1);
3402
3760
  for (const e of entries) {
3403
3761
  if (SKIP.has(e.name)) continue;
3404
- const path = join6(at, e.name);
3762
+ const path = join7(at, e.name);
3405
3763
  if (e.isDirectory()) {
3406
3764
  walk(path);
3407
3765
  continue;
3408
3766
  }
3409
3767
  if (!e.isFile()) continue;
3410
- const st = statSync2(path);
3411
- h.update(`${relative(root, path)}\0${st.mtimeMs}\0${st.size}
3768
+ const st = statSync3(path);
3769
+ h.update(`${relative2(root, path)}\0${st.mtimeMs}\0${st.size}
3412
3770
  `);
3413
3771
  any = true;
3414
3772
  }
@@ -3423,7 +3781,7 @@ function memberSourceStamp(dir) {
3423
3781
  function cacheKey(dir, opts) {
3424
3782
  const chant = resolveChant(dir);
3425
3783
  const canonical = JSON.stringify(opts, Object.keys(opts).sort());
3426
- return `${resolve2(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
3784
+ return `${resolve3(dir)}\0${chant.bin}\0${chant.version ?? ""}\0${canonical}`;
3427
3785
  }
3428
3786
  var cache = /* @__PURE__ */ new Map();
3429
3787
  var hits = 0;
@@ -3433,7 +3791,7 @@ function invalidateMember(dir) {
3433
3791
  cache.clear();
3434
3792
  return;
3435
3793
  }
3436
- const root = resolve2(dir);
3794
+ const root = resolve3(dir);
3437
3795
  for (const [key, entry] of cache) if (entry.dir === root) cache.delete(key);
3438
3796
  }
3439
3797
  async function memberIr(dir, opts = {}) {
@@ -3452,7 +3810,7 @@ async function memberIr(dir, opts = {}) {
3452
3810
  const ir = await graphIr(dir, opts);
3453
3811
  if (stamp === void 0 || memberSourceStamp(dir) !== stamp) return ir;
3454
3812
  cache.delete(key);
3455
- cache.set(key, { dir: resolve2(dir), stamp, ir });
3813
+ cache.set(key, { dir: resolve3(dir), stamp, ir });
3456
3814
  for (const oldest of cache.keys()) {
3457
3815
  if (cache.size <= size) break;
3458
3816
  cache.delete(oldest);
@@ -3460,6 +3818,70 @@ async function memberIr(dir, opts = {}) {
3460
3818
  return structuredClone(ir);
3461
3819
  }
3462
3820
 
3821
+ // src/carve-discovery.ts
3822
+ function carveStatusReader(projectDir) {
3823
+ return async (dir) => {
3824
+ const via = projectDir ?? dir;
3825
+ if (!meetsFloor(resolveChant(via).version, CARVE_STATUS_FLOOR)) return void 0;
3826
+ return carveStatus(dir, via).catch(() => void 0);
3827
+ };
3828
+ }
3829
+ var SKIP2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".chant", ".terraform", "cdk.out"]);
3830
+ function hasAnyCarveManifest(dir, io = fsIo) {
3831
+ const walk = (d) => {
3832
+ let entries;
3833
+ try {
3834
+ entries = io.readdir(d);
3835
+ } catch {
3836
+ return false;
3837
+ }
3838
+ for (const name of entries) {
3839
+ if (name.endsWith(CARVE_MANIFEST_SUFFIX)) return true;
3840
+ if (name.startsWith(".") || SKIP2.has(name)) continue;
3841
+ const full = join8(d, name);
3842
+ if (io.isDirectory(full) && walk(full)) return true;
3843
+ }
3844
+ return false;
3845
+ };
3846
+ return walk(dir);
3847
+ }
3848
+ var fsIo = {
3849
+ readdir: (d) => readdirSync5(d),
3850
+ isDirectory: (p) => {
3851
+ try {
3852
+ return statSync4(p).isDirectory();
3853
+ } catch {
3854
+ return false;
3855
+ }
3856
+ }
3857
+ };
3858
+ var cache2 = /* @__PURE__ */ new Map();
3859
+ async function carveStatesFor(dir) {
3860
+ const stamp = memberSourceStamp(dir);
3861
+ const held = cache2.get(dir);
3862
+ if (held && stamp !== void 0 && held.stamp === stamp) return held.states;
3863
+ const states = hasAnyCarveManifest(dir) ? await discoverCarveStates([dir], carveStatusReader(), nodeCarveIo, MANIFEST_SCAN_DEPTH) : /* @__PURE__ */ new Map();
3864
+ if (stamp !== void 0) cache2.set(dir, { stamp, states });
3865
+ return states;
3866
+ }
3867
+ async function carveStatesUnder(dirs) {
3868
+ const rank = { emitted: 0, bridged: 1, applied: 2 };
3869
+ const out = /* @__PURE__ */ new Map();
3870
+ for (const dir of dirs) {
3871
+ for (const s of (await carveStatesFor(dir)).values()) {
3872
+ const held = out.get(s.target);
3873
+ if (!held || rank[s.stage] > rank[held.stage]) out.set(s.target, s);
3874
+ }
3875
+ }
3876
+ return out;
3877
+ }
3878
+
3879
+ // src/estate.ts
3880
+ import { statSync as statSync5 } from "node:fs";
3881
+ import { availableParallelism } from "node:os";
3882
+ import { join as join9, resolve as resolve4, sep } from "node:path";
3883
+ import { composeStacks, shortStackNames } from "@intentius/pinhole";
3884
+
3463
3885
  // src/zoom-notes.ts
3464
3886
  function zoomNote(zoom, ir, compositeEdgesAttached) {
3465
3887
  const empty = ir.nodes.length === 0;
@@ -3562,7 +3984,10 @@ async function composeEstate(projectDirs, opts = {}) {
3562
3984
  const names = shortStackNames(projectDirs);
3563
3985
  const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
3564
3986
  name: names[i],
3565
- ir: await memberIr(dir, opts)
3987
+ // chant#2040: the manifests a member holds name the files its carved
3988
+ // entities were emitted into — joined here, where the member's root is
3989
+ // known, onto a COPY (the member IR is the #312 cache's own object).
3990
+ ir: joinCarvedSources(await memberIr(dir, opts), (await carveStatesFor(dir)).values(), dir).ir
3566
3991
  }));
3567
3992
  return composeStacks(stacks);
3568
3993
  }
@@ -3583,7 +4008,7 @@ var rec3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
3583
4008
  var str3 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
3584
4009
  var realIsDir = (p) => {
3585
4010
  try {
3586
- return statSync3(p).isDirectory();
4011
+ return statSync5(p).isDirectory();
3587
4012
  } catch {
3588
4013
  return false;
3589
4014
  }
@@ -3613,11 +4038,11 @@ function declaredSegments(path) {
3613
4038
  function pathAlignment(declared, dir, isDir = realIsDir) {
3614
4039
  const want = declaredSegments(declared);
3615
4040
  if (!want) return 0;
3616
- const root = resolve3(dir);
4041
+ const root = resolve4(dir);
3617
4042
  const have = root.split(sep).filter(Boolean);
3618
4043
  for (let k = Math.min(want.length, have.length); k >= 1; k--) {
3619
4044
  if (!have.slice(-k).every((s, i) => s === want[i])) continue;
3620
- if (!isDir(join7(root, ...want.slice(k)))) continue;
4045
+ if (!isDir(join9(root, ...want.slice(k)))) continue;
3621
4046
  return k;
3622
4047
  }
3623
4048
  return 0;
@@ -3647,10 +4072,10 @@ function joinNamespaceBindings(members, isDir = realIsDir) {
3647
4072
  if (!winner || tied) continue;
3648
4073
  const prev = best.get(winner.dir);
3649
4074
  if (prev === "ambiguous") continue;
3650
- const join21 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
3651
- if (!prev) best.set(winner.dir, { score: winner.score, join: join21 });
4075
+ const join22 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
4076
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join22 });
3652
4077
  else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
3653
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join21 });
4078
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join22 });
3654
4079
  }
3655
4080
  return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
3656
4081
  }
@@ -3691,7 +4116,7 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3691
4116
  const reason = firstLine2(err);
3692
4117
  try {
3693
4118
  const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3694
- const src = await memberIr(dir, srcOpts);
4119
+ const src = joinCarvedSources(await memberIr(dir, srcOpts), (await carveStatesFor(dir)).values(), dir).ir;
3695
4120
  for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3696
4121
  stacks[i] = { name, ir: src };
3697
4122
  unobserved.push({ name, reason });
@@ -3714,6 +4139,36 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
3714
4139
  }
3715
4140
 
3716
4141
  // src/helm-drift.ts
4142
+ function renderIdentity(record, observed) {
4143
+ const declared = {
4144
+ ...record.inputDigest ? { inputDigest: record.inputDigest } : {},
4145
+ ...record.contentDigest ? { contentDigest: record.contentDigest } : {}
4146
+ };
4147
+ const a = observed?.observation.attributes ?? {};
4148
+ const obs = {
4149
+ ...observed ? { release: observed.key.replace(/^release\//, "") } : {},
4150
+ ...typeof a.inputDigest === "string" ? { inputDigest: a.inputDigest } : {},
4151
+ ...typeof a.contentDigest === "string" ? { contentDigest: a.contentDigest } : {}
4152
+ };
4153
+ const base = { declared, observed: obs };
4154
+ if (!declared.inputDigest && !declared.contentDigest) return { match: "unpinned", ...base };
4155
+ if (!obs.inputDigest && !obs.contentDigest) return { match: "unrecorded", ...base };
4156
+ if (declared.contentDigest && obs.contentDigest) {
4157
+ return { match: declared.contentDigest === obs.contentDigest ? "content" : "mismatch", ...base };
4158
+ }
4159
+ if (declared.inputDigest && obs.inputDigest) {
4160
+ return { match: declared.inputDigest === obs.inputDigest ? "input" : "mismatch", ...base };
4161
+ }
4162
+ return { match: "unrecorded", ...base };
4163
+ }
4164
+ function observedReleaseFor(record, observed, namespace) {
4165
+ if (!observed) return void 0;
4166
+ const candidates = Object.entries(observed).filter(([key2]) => key2.startsWith("release/") && key2.split("/").pop() === record.name);
4167
+ if (candidates.length === 0) return void 0;
4168
+ const scoped = namespace ? candidates.find(([key2]) => key2 === `release/${namespace}/${record.name}`) : void 0;
4169
+ const [key, observation] = scoped ?? candidates[0];
4170
+ return { key, observation };
4171
+ }
3717
4172
  function countChanges(docs) {
3718
4173
  return docs.reduce((n, d) => n + d.changes.length, 0);
3719
4174
  }
@@ -3736,8 +4191,9 @@ function provenanceOf(live) {
3736
4191
  sourceRef: m.sourceRef
3737
4192
  };
3738
4193
  }
3739
- function helmRenderReport(record, live) {
3740
- const base = { render: record.name, chart: record.chart };
4194
+ function helmRenderReport(record, live, observed) {
4195
+ const identity = observed ? { identity: renderIdentity(record, observedReleaseFor(record, observed, live?.manifest.namespace)) } : {};
4196
+ const base = { render: record.name, chart: record.chart, ...identity };
3741
4197
  if (!live) {
3742
4198
  return { ...base, verdict: "unobserved", reason: record.contentDigest ? "no-stored-render" : "unpinned" };
3743
4199
  }
@@ -3761,21 +4217,24 @@ function helmRenderReport(record, live) {
3761
4217
  }
3762
4218
  return { ...base, verdict: "in-sync", counts, provenance };
3763
4219
  }
3764
- async function readHelmRenderDrift(projectDir, env, opts = {}) {
4220
+ async function readHelmRenderDrift(projectDir, env, opts = {}, observed) {
3765
4221
  const report = await helmRenders(projectDir, opts).catch(() => void 0);
3766
4222
  const records = report?.records ?? [];
3767
4223
  if (records.length === 0) return [];
3768
4224
  return mapPool(records, estateReadPool(records.length), async (record) => {
3769
- if (!record.contentDigest) return helmRenderReport(record, void 0);
4225
+ if (!record.contentDigest) return helmRenderReport(record, void 0, observed);
3770
4226
  const live = await helmRenderDiffLive(projectDir, record.contentDigest, env, opts).catch(() => void 0);
3771
- return helmRenderReport(record, live);
4227
+ return helmRenderReport(record, live, observed);
3772
4228
  });
3773
4229
  }
3774
4230
  function chartNameOf2(node) {
3775
4231
  const name = node.attrs?.name;
3776
4232
  return typeof name === "string" && name.length > 0 ? name : void 0;
3777
4233
  }
3778
- var SEVERITY = { drifted: 2, unobserved: 1, "in-sync": 0 };
4234
+ var SEVERITY = { drifted: 3, unobserved: 1, "in-sync": 0 };
4235
+ function severityOf(r) {
4236
+ return Math.max(SEVERITY[r.verdict], r.identity?.match === "mismatch" ? 2 : 0);
4237
+ }
3779
4238
  function applyHelmRenderDrift(ir, reports) {
3780
4239
  if (reports.length === 0) return { charts: 0, drifted: 0 };
3781
4240
  const byChart = /* @__PURE__ */ new Map();
@@ -3791,11 +4250,11 @@ function applyHelmRenderDrift(ir, reports) {
3791
4250
  const chartName = chartNameOf2(node);
3792
4251
  const matched = chartName ? byChart.get(chartName) : void 0;
3793
4252
  if (!matched || matched.length === 0) continue;
3794
- const worst = matched.reduce((a, b) => SEVERITY[b.verdict] > SEVERITY[a.verdict] ? b : a);
4253
+ const worst = matched.reduce((a, b) => severityOf(b) > severityOf(a) ? b : a);
3795
4254
  const attrs = node.attrs ??= {};
3796
4255
  attrs._renderDrift = matched.length === 1 ? worst : { ...worst, renders: matched };
3797
4256
  charts++;
3798
- if (worst.verdict === "drifted") {
4257
+ if (worst.verdict === "drifted" || matched.some((r) => r.identity?.match === "mismatch")) {
3799
4258
  attrs._status = "warn";
3800
4259
  drifted++;
3801
4260
  }
@@ -4335,7 +4794,7 @@ function projectHelmLogical(ir) {
4335
4794
 
4336
4795
  // src/logical-kustomize.ts
4337
4796
  import { existsSync as existsSync5 } from "node:fs";
4338
- import { join as join8 } from "node:path";
4797
+ import { join as join10 } from "node:path";
4339
4798
  function overlayBoxTitle(name) {
4340
4799
  return `overlay ${name}`;
4341
4800
  }
@@ -4355,7 +4814,7 @@ function kustomizationRoot(bases, dir, exists) {
4355
4814
  let current = dir;
4356
4815
  for (let i = 0; i < 4 && current; i++) {
4357
4816
  for (const base of bases) {
4358
- if (exists(join8(base, current, "kustomization.yaml")) || exists(join8(base, current, "kustomization.yml"))) {
4817
+ if (exists(join10(base, current, "kustomization.yaml")) || exists(join10(base, current, "kustomization.yml"))) {
4359
4818
  return current;
4360
4819
  }
4361
4820
  }
@@ -5086,11 +5545,11 @@ import {
5086
5545
  } from "@intentius/pinhole";
5087
5546
 
5088
5547
  // src/icon-packs.ts
5089
- import { readFileSync as readFileSync6 } from "node:fs";
5090
- import { dirname as dirname3, join as join9 } from "node:path";
5548
+ import { readFileSync as readFileSync7 } from "node:fs";
5549
+ import { dirname as dirname4, join as join11 } from "node:path";
5091
5550
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5092
- var ICON_ROOT = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "web", "icons");
5093
- var cache2 = /* @__PURE__ */ new Map();
5551
+ var ICON_ROOT = join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web", "icons");
5552
+ var cache3 = /* @__PURE__ */ new Map();
5094
5553
  function unprefix(body) {
5095
5554
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
5096
5555
  }
@@ -5109,9 +5568,9 @@ function plate(glyph) {
5109
5568
  return { ...glyph, body: `${ground}<g transform="${inset}">${glyph.body}</g>` };
5110
5569
  }
5111
5570
  function loadIcon(rel) {
5112
- const hit = cache2.get(rel);
5571
+ const hit = cache3.get(rel);
5113
5572
  if (hit) return hit;
5114
- const raw = readFileSync6(join9(ICON_ROOT, `${rel}.svg`), "utf8");
5573
+ const raw = readFileSync7(join11(ICON_ROOT, `${rel}.svg`), "utf8");
5115
5574
  const open = raw.indexOf("<svg");
5116
5575
  const openEnd = raw.indexOf(">", open);
5117
5576
  const close = raw.lastIndexOf("</svg");
@@ -5120,7 +5579,7 @@ function loadIcon(rel) {
5120
5579
  if (!viewBox) throw new Error(`vendored icon has no viewBox: ${rel}.svg`);
5121
5580
  const bare = { body: unprefix(raw.slice(openEnd + 1, close).trim()), colored: true, viewBox };
5122
5581
  const glyph = PLATED.has(rel) ? plate(bare) : bare;
5123
- cache2.set(rel, glyph);
5582
+ cache3.set(rel, glyph);
5124
5583
  return glyph;
5125
5584
  }
5126
5585
  var K8S_ICON_BY_KIND = {
@@ -5176,9 +5635,9 @@ function helmIconFor(kind) {
5176
5635
  var SUPPORTED_MAJOR = 1;
5177
5636
  var refuse = (error, remedy) => ({ ok: false, refusal: { error, code: "carve-report", remedy } });
5178
5637
  var HOW_TO_GET_ONE = "Generate one with `chant carve advise --from <terraform-dir> --report report.json` (chant's read-only Terraform peelability advisor), then `behold carve report.json`.";
5179
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5638
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5180
5639
  function parseCarveReport(value) {
5181
- if (!isRecord2(value)) {
5640
+ if (!isRecord3(value)) {
5182
5641
  return refuse("That file isn't a carve report \u2014 its top level isn't a JSON object.", HOW_TO_GET_ONE);
5183
5642
  }
5184
5643
  if (!Array.isArray(value.resources)) {
@@ -5188,7 +5647,7 @@ function parseCarveReport(value) {
5188
5647
  );
5189
5648
  }
5190
5649
  const bad = value.resources.findIndex(
5191
- (r) => !isRecord2(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
5650
+ (r) => !isRecord3(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string"
5192
5651
  );
5193
5652
  if (bad >= 0) {
5194
5653
  return refuse(
@@ -5233,384 +5692,136 @@ function statusForBand(band, score) {
5233
5692
  switch (band) {
5234
5693
  case "clean leaf":
5235
5694
  return "good";
5236
- case "carvable w/ edits":
5237
- return "warn";
5238
- case "leave in Terraform":
5239
- return "neutral";
5240
- default:
5241
- return score >= 80 ? "good" : score >= 50 ? "warn" : "neutral";
5242
- }
5243
- }
5244
- function verdictForStatus(status) {
5245
- return status === "good" ? "carve now" : status === "warn" ? "boundary work" : "leave in Terraform";
5246
- }
5247
- function tfTypeOf(address, kind) {
5248
- if (kind === "module" || address.startsWith("module.")) return "module";
5249
- const dot = address.indexOf(".");
5250
- return dot > 0 ? address.slice(0, dot) : address;
5251
- }
5252
- var directionOf = (e) => e.direction ?? "inbound";
5253
- function boundaryEdgesOf(r) {
5254
- const b = r.boundary;
5255
- if (!b) return [];
5256
- const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
5257
- return list3.filter((e) => isRecord2(e) && typeof e.survivor === "string" && typeof e.carved === "string");
5258
- }
5259
- function scoreArithmetic(r) {
5260
- const b = r.breakdown;
5261
- if (!b || b.tier === null) return `${r.score} (no known native mapping \u2014 nothing to carve into)`;
5262
- const p = b.penalties ?? {};
5263
- const terms = [];
5264
- if (p.inbound) terms.push(`- 12x${b.inbound ?? 0} inbound`);
5265
- if (p.outbound) terms.push(`- 4x${b.outbound ?? 0} outbound`);
5266
- if (p.outputs) terms.push(`- 4x${b.outputs ?? 0} output`);
5267
- if (p.tier) terms.push(`- 15x${(b.tier ?? 1) - 1} tier${b.tier}`);
5268
- if (p.dynamic) terms.push(`- 10 dynamic`);
5269
- if (p.instances) terms.push(`- 3x${(b.instances ?? 1) - 1} instances`);
5270
- if (!terms.length) return `100 (no penalties) = ${r.score}`;
5271
- const raw = 100 + Object.values(p).reduce((s, n) => s + (n ?? 0), 0);
5272
- const clamped = raw !== r.score ? ` (clamped from ${raw})` : "";
5273
- return `100 ${terms.join(" ")} = ${r.score}${clamped}`;
5274
- }
5275
- function boundaryWorkOf(r) {
5276
- const b = r.breakdown;
5277
- if (!b || b.tier === null) return "no known native mapping (unsupported provider/type)";
5278
- const parts = [];
5279
- if (b.inbound) parts.push(`${b.inbound} inbound (a data-source patch each)`);
5280
- if (b.outbound) parts.push(`${b.outbound} outbound (a deferred input each)`);
5281
- if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (a one-line rewrite each)`);
5282
- if ((b.tier ?? 1) > 1) parts.push(`tier ${b.tier} map`);
5283
- if (b.hasDynamic) parts.push("count/for_each/data present");
5284
- if ((b.instances ?? 1) > 1) parts.push(`${b.instances} instances`);
5285
- return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
5286
- }
5287
- function carveCardFields(node) {
5288
- const score = node.attrs.score;
5289
- const carve = node.attrs.carve;
5290
- if (typeof score !== "number") return void 0;
5291
- return [
5292
- { label: "score", value: String(score) },
5293
- ...typeof carve === "string" ? [{ label: "carve", value: carve }] : []
5294
- ];
5295
- }
5296
- function carveReportToIr(report) {
5297
- const resources = report.resources ?? [];
5298
- const known = new Set(resources.map((r) => r.address));
5299
- const nodes = resources.map((r) => {
5300
- const status = statusForBand(r.band, r.score);
5301
- const b = r.breakdown ?? {};
5302
- const edges2 = boundaryEdgesOf(r);
5303
- const patches = edges2.filter((e) => e.carved === r.address && directionOf(e) === "inbound").map((e) => e.survivor);
5304
- const inputs = edges2.filter((e) => e.carved === r.address && directionOf(e) === "outbound").map((e) => e.survivor);
5305
- return {
5306
- id: r.address,
5307
- kind: tfTypeOf(r.address, r.kind),
5308
- lexicon: "terraform",
5309
- attrs: {
5310
- // The drift palette's channel — `_`-prefixed, so it paints the card and
5311
- // stays out of both the card's fields and the inspect pane's list.
5312
- _status: status,
5313
- score: r.score,
5314
- carve: verdictForStatus(status),
5315
- band: r.band,
5316
- arithmetic: scoreArithmetic(r),
5317
- boundaryWork: boundaryWorkOf(r),
5318
- ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
5319
- tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
5320
- inbound: b.inbound ?? 0,
5321
- outbound: b.outbound ?? 0,
5322
- outputs: b.outputs ?? 0,
5323
- instances: b.instances ?? 1,
5324
- dynamic: b.hasDynamic ?? false,
5325
- // The predicted diff, when the report carries the edge lists: who needs
5326
- // a `data` source the moment this is carved, and what becomes a
5327
- // deploy-time input. Absent (not empty) when the report has no lists —
5328
- // "none" and "not reported" are different claims.
5329
- ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
5330
- ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
5331
- }
5332
- };
5333
- });
5334
- const seen = /* @__PURE__ */ new Set();
5335
- const edges = [];
5336
- for (const r of resources) {
5337
- for (const e of boundaryEdgesOf(r)) {
5338
- const direction = directionOf(e);
5339
- const from = direction === "inbound" ? e.survivor : e.carved;
5340
- const to = direction === "inbound" ? e.carved : e.survivor;
5341
- if (!known.has(from) || !known.has(to)) continue;
5342
- const key = `${from}|${to}`;
5343
- if (seen.has(key)) continue;
5344
- seen.add(key);
5345
- edges.push({
5346
- from,
5347
- to,
5348
- kind: "ref",
5349
- viaAttr: direction,
5350
- ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
5351
- });
5352
- }
5353
- }
5354
- const byStack = {};
5355
- for (const order of ["good", "warn", "neutral"]) {
5356
- const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
5357
- if (members.length) byStack[verdictForStatus(order)] = members;
5358
- }
5359
- return { nodes, edges, groups: { byStack } };
5360
- }
5361
- function carveNote(report, ir) {
5362
- const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
5363
- const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
5364
- if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
5365
- 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.`;
5366
- }
5367
-
5368
- // src/carve-manifest.ts
5369
- import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
5370
- import { dirname as dirname4, join as join10 } from "node:path";
5371
- var CARVE_MANIFEST_SUFFIX = ".carve.json";
5372
- 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.";
5373
- var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5374
- var strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
5375
- function parseCarveManifest(value) {
5376
- if (!isRecord3(value)) return null;
5377
- if (value.version !== 1) return null;
5378
- if (typeof value.target !== "string" || !value.target) return null;
5379
- return value;
5380
- }
5381
- function readCarveManifest(path, readFile = (p) => readFileSync7(p, "utf8")) {
5382
- let text;
5383
- try {
5384
- text = readFile(path);
5385
- } catch {
5386
- return null;
5387
- }
5388
- try {
5389
- return parseCarveManifest(JSON.parse(text));
5390
- } catch {
5391
- return null;
5392
- }
5393
- }
5394
- function carveStageOf(m) {
5395
- if (m.apply) return "applied";
5396
- if (m.bridge) return "bridged";
5397
- return "emitted";
5398
- }
5399
- function carveWordFor(stage) {
5400
- switch (stage) {
5401
- case "applied":
5402
- return "carved \u2192 chant";
5403
- case "bridged":
5404
- return "bridged \u2014 apply is yours";
5405
- default:
5406
- return "emitted \u2014 not bridged";
5407
- }
5408
- }
5409
- function carveToneFor(stage) {
5410
- return stage === "applied" ? "good" : "accent";
5411
- }
5412
- function carveStageNote(state) {
5413
- switch (state.stage) {
5414
- case "applied":
5415
- 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.";
5416
- case "bridged":
5417
- return "emitted and bridged \u2014 the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
5418
- default:
5419
- return "emitted \u2014 typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
5420
- }
5421
- }
5422
- function carveStateOf(m, path) {
5423
- const stage = carveStageOf(m);
5424
- const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} \u2190 ${p?.survivor ?? "a survivor"}`);
5425
- return {
5426
- target: m.target,
5427
- ...m.tfType ? { tfType: m.tfType } : {},
5428
- stage,
5429
- graduated: stage === "applied",
5430
- // The last stamped step wins — that is the age of the state, not the age
5431
- // of the carve.
5432
- ...m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {},
5433
- files: strings(m.emit?.files),
5434
- excised: strings(m.bridge?.excised),
5435
- deferredInputs: deferred,
5436
- ...m.apply ? {
5437
- ownership: {
5438
- ...m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {},
5439
- ...m.apply.marker?.env ? { env: m.apply.marker.env } : {},
5440
- ...m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}
5441
- }
5442
- } : {},
5443
- ...m.from ? { from: m.from } : {},
5444
- outDir: dirname4(path),
5445
- path
5446
- };
5447
- }
5448
- var nodeCarveIo = {
5449
- readdir: (dir) => readdirSync4(dir),
5450
- isDirectory: (p) => {
5451
- try {
5452
- return statSync4(p).isDirectory();
5453
- } catch {
5454
- return false;
5455
- }
5456
- },
5457
- readFile: (p) => readFileSync7(p, "utf8")
5458
- };
5459
- var MANIFEST_SCAN_DEPTH = 2;
5460
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
5461
- function listCarveManifests(dir, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
5462
- const out = [];
5463
- const walk = (d, level) => {
5464
- let entries;
5465
- try {
5466
- entries = io.readdir(d).sort();
5467
- } catch {
5468
- return;
5469
- }
5470
- for (const name of entries) {
5471
- const full = join10(d, name);
5472
- if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
5473
- out.push(full);
5474
- continue;
5475
- }
5476
- if (level >= depth) continue;
5477
- if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
5478
- if (io.isDirectory(full)) walk(full, level + 1);
5479
- }
5480
- };
5481
- walk(dir, 0);
5482
- return out;
5483
- }
5484
- function readCarveStates(dirs, io = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH) {
5485
- const rank = { emitted: 0, bridged: 1, applied: 2 };
5486
- const byTarget = /* @__PURE__ */ new Map();
5487
- for (const dir of dirs) {
5488
- for (const path of listCarveManifests(dir, io, depth)) {
5489
- const manifest = readCarveManifest(path, io.readFile);
5490
- if (!manifest) continue;
5491
- const state = carveStateOf(manifest, path);
5492
- const held = byTarget.get(state.target);
5493
- if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
5494
- }
5695
+ case "carvable w/ edits":
5696
+ return "warn";
5697
+ case "leave in Terraform":
5698
+ return "neutral";
5699
+ default:
5700
+ return score >= 80 ? "good" : score >= 50 ? "warn" : "neutral";
5495
5701
  }
5496
- return byTarget;
5497
5702
  }
5498
- function carveProgress(states, total) {
5499
- let applied = 0;
5500
- let bridged = 0;
5501
- let emitted = 0;
5502
- for (const s of states) {
5503
- if (s.stage === "applied") applied++;
5504
- else if (s.stage === "bridged") bridged++;
5505
- else emitted++;
5506
- }
5507
- const inFlight = bridged + emitted;
5508
- const parts = [];
5509
- if (emitted) parts.push(`${emitted} emitted`);
5510
- if (bridged) parts.push(`${bridged} bridged`);
5511
- return {
5512
- applied,
5513
- bridged,
5514
- emitted,
5515
- inFlight,
5516
- total: typeof total === "number" ? total : null,
5517
- label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
5518
- detail: parts.length ? `${parts.join(", ")} \u2014 apply is yours` : ""
5519
- };
5703
+ function verdictForStatus(status) {
5704
+ return status === "good" ? "carve now" : status === "warn" ? "boundary work" : "leave in Terraform";
5520
5705
  }
5521
- function applyCommandFor(state, labels = {}) {
5522
- const env = state.ownership?.env ?? "<env>";
5523
- const stack = state.ownership?.stack ?? "<stack>";
5524
- const from = labels.from ?? state.from ?? "<terraform-dir>";
5525
- const out = labels.out ?? state.outDir;
5526
- return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
5706
+ function tfTypeOf(address, kind) {
5707
+ if (kind === "module" || address.startsWith("module.")) return "module";
5708
+ const dot = address.indexOf(".");
5709
+ return dot > 0 ? address.slice(0, dot) : address;
5527
5710
  }
5528
- function carveStatePayload(states, total, labels = () => ({})) {
5529
- const list3 = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
5530
- return {
5531
- manifests: list3.length,
5532
- progress: carveProgress(list3, total),
5533
- states: list3.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
5534
- apply: { human: true, note: APPLY_IS_HUMAN }
5535
- };
5711
+ var directionOf = (e) => e.direction ?? "inbound";
5712
+ function boundaryEdgesOf(r) {
5713
+ const b = r.boundary;
5714
+ if (!b) return [];
5715
+ const list3 = Array.isArray(b) ? b : [...b.inbound ?? [], ...b.outbound ?? []];
5716
+ return list3.filter((e) => isRecord3(e) && typeof e.survivor === "string" && typeof e.carved === "string");
5536
5717
  }
5537
- function splitCarveState(tfIr, states, opts = {}) {
5538
- if (states.size === 0) return { tf: tfIr, graduated: [] };
5539
- const shorten = opts.shorten ?? ((p) => p);
5540
- const source = (files) => files.map(shorten).join(", ");
5541
- const gone = /* @__PURE__ */ new Set();
5542
- const graduated = [];
5543
- const nodes = [];
5544
- for (const node of tfIr.nodes) {
5545
- const state = states.get(node.id);
5546
- if (!state) {
5547
- nodes.push(node);
5548
- continue;
5549
- }
5550
- if (state.graduated) {
5551
- gone.add(node.id);
5552
- graduated.push({
5553
- ...node,
5554
- attrs: {
5555
- ...node.attrs,
5556
- _status: "good",
5557
- carve: carveWordFor(state.stage),
5558
- carveState: state.stage,
5559
- ...state.ownership?.stack ? { ownedStack: state.ownership.stack } : {},
5560
- ...state.ownership?.env ? { ownedEnv: state.ownership.env } : {},
5561
- ...state.at ? { carvedAt: state.at } : {},
5562
- ...state.files.length ? { chantSource: source(state.files) } : {}
5563
- }
5564
- });
5565
- continue;
5566
- }
5567
- nodes.push({
5568
- ...node,
5718
+ function scoreArithmetic(r) {
5719
+ const b = r.breakdown;
5720
+ if (!b || b.tier === null) return `${r.score} (no known native mapping \u2014 nothing to carve into)`;
5721
+ const p = b.penalties ?? {};
5722
+ const terms = [];
5723
+ if (p.inbound) terms.push(`- 12x${b.inbound ?? 0} inbound`);
5724
+ if (p.outbound) terms.push(`- 4x${b.outbound ?? 0} outbound`);
5725
+ if (p.outputs) terms.push(`- 4x${b.outputs ?? 0} output`);
5726
+ if (p.tier) terms.push(`- 15x${(b.tier ?? 1) - 1} tier${b.tier}`);
5727
+ if (p.dynamic) terms.push(`- 10 dynamic`);
5728
+ if (p.instances) terms.push(`- 3x${(b.instances ?? 1) - 1} instances`);
5729
+ if (!terms.length) return `100 (no penalties) = ${r.score}`;
5730
+ const raw = 100 + Object.values(p).reduce((s, n) => s + (n ?? 0), 0);
5731
+ const clamped = raw !== r.score ? ` (clamped from ${raw})` : "";
5732
+ return `100 ${terms.join(" ")} = ${r.score}${clamped}`;
5733
+ }
5734
+ function boundaryWorkOf(r) {
5735
+ const b = r.breakdown;
5736
+ if (!b || b.tier === null) return "no known native mapping (unsupported provider/type)";
5737
+ const parts = [];
5738
+ if (b.inbound) parts.push(`${b.inbound} inbound (a data-source patch each)`);
5739
+ if (b.outbound) parts.push(`${b.outbound} outbound (a deferred input each)`);
5740
+ if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (a one-line rewrite each)`);
5741
+ if ((b.tier ?? 1) > 1) parts.push(`tier ${b.tier} map`);
5742
+ if (b.hasDynamic) parts.push("count/for_each/data present");
5743
+ if ((b.instances ?? 1) > 1) parts.push(`${b.instances} instances`);
5744
+ return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
5745
+ }
5746
+ function carveCardFields(node) {
5747
+ const score = node.attrs.score;
5748
+ const carve = node.attrs.carve;
5749
+ if (typeof score !== "number") return void 0;
5750
+ return [
5751
+ { label: "score", value: String(score) },
5752
+ ...typeof carve === "string" ? [{ label: "carve", value: carve }] : []
5753
+ ];
5754
+ }
5755
+ function carveReportToIr(report) {
5756
+ const resources = report.resources ?? [];
5757
+ const known = new Set(resources.map((r) => r.address));
5758
+ const nodes = resources.map((r) => {
5759
+ const status = statusForBand(r.band, r.score);
5760
+ const b = r.breakdown ?? {};
5761
+ const edges2 = boundaryEdgesOf(r);
5762
+ const patches = edges2.filter((e) => e.carved === r.address && directionOf(e) === "inbound").map((e) => e.survivor);
5763
+ const inputs = edges2.filter((e) => e.carved === r.address && directionOf(e) === "outbound").map((e) => e.survivor);
5764
+ return {
5765
+ id: r.address,
5766
+ kind: tfTypeOf(r.address, r.kind),
5767
+ lexicon: "terraform",
5569
5768
  attrs: {
5570
- ...node.attrs,
5571
- _status: carveToneFor(state.stage),
5572
- carve: carveWordFor(state.stage),
5573
- carveState: state.stage,
5574
- ...state.at ? { carvedAt: state.at } : {},
5575
- ...state.files.length ? { chantSource: source(state.files) } : {},
5576
- ...state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {},
5577
- ...state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}
5769
+ // The drift palette's channel — `_`-prefixed, so it paints the card and
5770
+ // stays out of both the card's fields and the inspect pane's list.
5771
+ _status: status,
5772
+ score: r.score,
5773
+ carve: verdictForStatus(status),
5774
+ band: r.band,
5775
+ arithmetic: scoreArithmetic(r),
5776
+ boundaryWork: boundaryWorkOf(r),
5777
+ ...r.mapsTo ? { mapsTo: r.mapsTo } : {},
5778
+ tier: b.tier === null || b.tier === void 0 ? "none" : b.tier,
5779
+ inbound: b.inbound ?? 0,
5780
+ outbound: b.outbound ?? 0,
5781
+ outputs: b.outputs ?? 0,
5782
+ instances: b.instances ?? 1,
5783
+ dynamic: b.hasDynamic ?? false,
5784
+ // The predicted diff, when the report carries the edge lists: who needs
5785
+ // a `data` source the moment this is carved, and what becomes a
5786
+ // deploy-time input. Absent (not empty) when the report has no lists —
5787
+ // "none" and "not reported" are different claims.
5788
+ ...patches.length ? { patchOnCarve: patches.join(", ") } : {},
5789
+ ...inputs.length ? { deferredInputs: inputs.join(", ") } : {}
5578
5790
  }
5579
- });
5791
+ };
5792
+ });
5793
+ const seen = /* @__PURE__ */ new Set();
5794
+ const edges = [];
5795
+ for (const r of resources) {
5796
+ for (const e of boundaryEdgesOf(r)) {
5797
+ const direction = directionOf(e);
5798
+ const from = direction === "inbound" ? e.survivor : e.carved;
5799
+ const to = direction === "inbound" ? e.carved : e.survivor;
5800
+ if (!known.has(from) || !known.has(to)) continue;
5801
+ const key = `${from}|${to}`;
5802
+ if (seen.has(key)) continue;
5803
+ seen.add(key);
5804
+ edges.push({
5805
+ from,
5806
+ to,
5807
+ kind: "ref",
5808
+ viaAttr: direction,
5809
+ ...e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}
5810
+ });
5811
+ }
5580
5812
  }
5581
5813
  const byStack = {};
5582
- for (const [band, members] of Object.entries(tfIr.groups?.byStack ?? {})) {
5583
- const left = members.filter((id) => !gone.has(id));
5584
- if (left.length) byStack[band] = left;
5814
+ for (const order of ["good", "warn", "neutral"]) {
5815
+ const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address);
5816
+ if (members.length) byStack[verdictForStatus(order)] = members;
5585
5817
  }
5586
- return {
5587
- tf: {
5588
- nodes,
5589
- edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
5590
- groups: { ...tfIr.groups, byStack }
5591
- },
5592
- graduated
5593
- };
5594
- }
5595
- var GRADUATED_BAND = "carved \u2192 chant";
5596
- function bandGraduated(tf, graduated) {
5597
- if (graduated.length === 0) return tf;
5598
- return {
5599
- nodes: [...graduated, ...tf.nodes],
5600
- edges: tf.edges,
5601
- groups: {
5602
- ...tf.groups,
5603
- byStack: {
5604
- [GRADUATED_BAND]: graduated.map((n) => n.id),
5605
- ...tf.groups?.byStack ?? {}
5606
- }
5607
- }
5608
- };
5818
+ return { nodes, edges, groups: { byStack } };
5609
5819
  }
5610
- function carveStateNote(progress, states) {
5611
- if (states.size === 0) return "";
5612
- const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
5613
- return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
5820
+ function carveNote(report, ir) {
5821
+ const counts = Object.entries(report.bands ?? {}).filter(([, n]) => n > 0).map(([band, n]) => `${n} ${band}`).join(" \xB7 ");
5822
+ const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` \u2014 ${counts}` : ""}`;
5823
+ if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`;
5824
+ 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.`;
5614
5825
  }
5615
5826
 
5616
5827
  // src/ops-lens.ts
@@ -5884,9 +6095,12 @@ function nodeFor(op, placed, guards) {
5884
6095
  ...profile ? { profile } : {},
5885
6096
  ...policy ? { policy } : {},
5886
6097
  // The resolved args, verbatim from the IR. They carry the Op's SCOPE
5887
- // (env, stack, template path) and never an estate entity id — see the
5888
- // module doc on why no edge is drawn from them.
6098
+ // (env, stack, template path); which of them identify an ENTITY is the
6099
+ // contract's to say, and 0.54's op.json says it in `entities` below.
5889
6100
  ...args ? { args } : {},
6101
+ // chant#2022: what the step touches, verbatim. `entities` prints on the
6102
+ // card; `_entities` is what `joinStepEntities` resolves.
6103
+ ...step.entities?.length ? { entities: step.entities.join(", "), _entities: step.entities } : {},
5890
6104
  ...step.outcomeAttribute ? { outcome: step.outcomeAttribute.from ? `${step.outcomeAttribute.name} \u2190 ${step.outcomeAttribute.from}` : step.outcomeAttribute.name } : {},
5891
6105
  ...op.activityContracts?.[step.fn] ? { contract: "declared (args/returns JSON Schema in op.json)" } : {}
5892
6106
  }
@@ -5988,17 +6202,83 @@ function opCardFields(node) {
5988
6202
  function opsNote(ops, ir) {
5989
6203
  const gates = ir.nodes.filter((n) => n.attrs._step === "gate").length;
5990
6204
  const rules = ir.nodes.filter((n) => n.attrs._step === "rule");
5991
- const steps = ir.nodes.length - rules.length;
6205
+ const estate = ir.nodes.filter((n) => n.lexicon !== "op").length;
6206
+ const steps = ir.nodes.length - rules.length - estate;
5992
6207
  const dangling = rules.filter((n) => n.attrs.dangling !== void 0).length;
5993
6208
  const counts = [
5994
6209
  `${ops.length} declared Op${ops.length === 1 ? "" : "s"}`,
5995
6210
  `${steps} step${steps === 1 ? "" : "s"}`,
5996
6211
  ...gates ? [`${gates} gate${gates === 1 ? "" : "s"}`] : [],
5997
- ...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : []
6212
+ ...rules.length ? [`${rules.length} converge rule${rules.length === 1 ? "" : "s"}`] : [],
6213
+ ...entityCountClause(ir)
5998
6214
  ].join(" \xB7 ");
5999
6215
  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.` : "") : "";
6000
6216
  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.`;
6001
6217
  }
6218
+ var ESTATE_BOX = "estate";
6219
+ function joinStepEntities(ir, estate) {
6220
+ const steps = ir.nodes.filter((n) => Array.isArray(n.attrs._entities));
6221
+ if (steps.length === 0) return { refs: 0, resolved: 0 };
6222
+ const byId = /* @__PURE__ */ new Map();
6223
+ const byLeaf = /* @__PURE__ */ new Map();
6224
+ for (const n of estate?.nodes ?? []) {
6225
+ byId.set(n.id, n);
6226
+ for (const leaf of stringLeaves(n.attrs)) {
6227
+ const list3 = byLeaf.get(leaf.value);
6228
+ if (list3) {
6229
+ if (!list3.includes(n)) list3.push(n);
6230
+ } else byLeaf.set(leaf.value, [n]);
6231
+ }
6232
+ }
6233
+ const present = new Set(ir.nodes.map((n) => n.id));
6234
+ let refs = 0;
6235
+ let resolvedCount = 0;
6236
+ for (const step of steps) {
6237
+ const links = { resolved: [], unresolved: [] };
6238
+ for (const value of step.attrs._entities) {
6239
+ refs++;
6240
+ const targets = [];
6241
+ const idHit = byId.get(value);
6242
+ if (idHit) targets.push({ node: idHit, via: "id" });
6243
+ for (const n of byLeaf.get(value) ?? []) if (n !== idHit) targets.push({ node: n, via: "attr" });
6244
+ if (targets.length === 0) {
6245
+ links.unresolved.push(value);
6246
+ continue;
6247
+ }
6248
+ resolvedCount++;
6249
+ for (const { node, via } of targets) {
6250
+ links.resolved.push({ value, node: node.id, via });
6251
+ if (!present.has(node.id)) {
6252
+ present.add(node.id);
6253
+ ir.nodes.push({ ...node, attrs: { ...node.attrs, _touchedBy: [step.id] } });
6254
+ (ir.groups.byStack ??= {})[ESTATE_BOX] = [...ir.groups.byStack[ESTATE_BOX] ?? [], node.id];
6255
+ } else {
6256
+ const held = ir.nodes.find((n) => n.id === node.id);
6257
+ const touched = held.attrs._touchedBy ?? [];
6258
+ if (!touched.includes(step.id)) held.attrs._touchedBy = [...touched, step.id];
6259
+ }
6260
+ ir.edges.push({ from: step.id, to: node.id, kind: "ref", viaAttr: "entities" });
6261
+ }
6262
+ }
6263
+ step.attrs._entityLinks = links;
6264
+ }
6265
+ return { refs, resolved: resolvedCount };
6266
+ }
6267
+ function entityCountClause(ir) {
6268
+ let refs = 0;
6269
+ let resolved = 0;
6270
+ for (const n of ir.nodes) {
6271
+ const links = n.attrs._entityLinks;
6272
+ if (links) {
6273
+ refs += links.resolved.length + links.unresolved.length;
6274
+ resolved += links.resolved.length;
6275
+ } else if (Array.isArray(n.attrs._entities)) {
6276
+ refs += n.attrs._entities.length;
6277
+ }
6278
+ }
6279
+ if (refs === 0) return [];
6280
+ return [`${refs} entity ref${refs === 1 ? "" : "s"}, ${resolved} linked`];
6281
+ }
6002
6282
 
6003
6283
  // src/render.ts
6004
6284
  registerPack({ lexicon: "k8s", iconFor: k8sIconFor });
@@ -6112,7 +6392,10 @@ function renderCarveEstate(tfIr, appIr, opts) {
6112
6392
  }
6113
6393
  function withCarveState(tfIr, appIr, opts) {
6114
6394
  const states = opts.carved ?? /* @__PURE__ */ new Map();
6115
- const { tf, graduated } = splitCarveState(tfIr, states, { ...opts.shorten ? { shorten: opts.shorten } : {} });
6395
+ const { tf, graduated } = splitCarveState(tfIr, states, {
6396
+ ...opts.shorten ? { shorten: opts.shorten } : {},
6397
+ ...opts.emitted ? { emitted: opts.emitted } : {}
6398
+ });
6116
6399
  const ns = namespaceAppIr(appIr, opts.appTitle);
6117
6400
  const progress = carveProgress(states.values(), tfIr.nodes.length);
6118
6401
  return {
@@ -6452,12 +6735,12 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
6452
6735
  }
6453
6736
 
6454
6737
  // src/carve-actions.ts
6455
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
6456
- import { isAbsolute, join as join13, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
6738
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync10, readdirSync as readdirSync6, statSync as statSync6 } from "node:fs";
6739
+ import { isAbsolute as isAbsolute2, join as join14, relative as relative3, resolve as resolve5, sep as sep2 } from "node:path";
6457
6740
 
6458
6741
  // src/layout.ts
6459
6742
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
6460
- import { join as join11 } from "node:path";
6743
+ import { join as join12 } from "node:path";
6461
6744
  var LAYOUT_DIR = ".behold";
6462
6745
  var LAYOUT_FILE = "layout.json";
6463
6746
  var MAX_BODY_BYTES = 64 * 1024;
@@ -6498,7 +6781,7 @@ function lensFromQuery(params) {
6498
6781
  return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+");
6499
6782
  }
6500
6783
  function layoutPath(projectDir) {
6501
- return join11(projectDir, LAYOUT_DIR, LAYOUT_FILE);
6784
+ return join12(projectDir, LAYOUT_DIR, LAYOUT_FILE);
6502
6785
  }
6503
6786
  function unwritableReason(projectDir) {
6504
6787
  if (!projectDir || !existsSync6(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
@@ -6554,7 +6837,7 @@ function writeLens(projectDir, lens, deltas) {
6554
6837
  }
6555
6838
  const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
6556
6839
  if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
6557
- mkdirSync2(join11(projectDir, LAYOUT_DIR), { recursive: true });
6840
+ mkdirSync2(join12(projectDir, LAYOUT_DIR), { recursive: true });
6558
6841
  const tmp = `${file}.tmp`;
6559
6842
  writeFileSync2(tmp, body, "utf8");
6560
6843
  renameSync(tmp, file);
@@ -6640,7 +6923,7 @@ function reanchorEdges(svg, moved) {
6640
6923
  // src/carve-live.ts
6641
6924
  import { spawn as spawn3 } from "node:child_process";
6642
6925
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
6643
- import { join as join12 } from "node:path";
6926
+ import { join as join13 } from "node:path";
6644
6927
 
6645
6928
  // src/scratch.ts
6646
6929
  var PROTECTED_INFRA = [
@@ -6728,9 +7011,9 @@ async function teardownScratchFloci() {
6728
7011
  await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
6729
7012
  }
6730
7013
  async function applyIntoFloci(fromDir, step) {
6731
- const template = join12(fromDir, "floci-override.tf.disabled");
7014
+ const template = join13(fromDir, "floci-override.tf.disabled");
6732
7015
  if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
6733
- writeFileSync3(join12(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
7016
+ writeFileSync3(join13(fromDir, "floci_override.tf"), armOverride(readFileSync9(template, "utf8"), LIVE_PORT));
6734
7017
  const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
6735
7018
  if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
6736
7019
  const apply = await step(
@@ -6780,7 +7063,7 @@ async function runCarveObserve(demo, select) {
6780
7063
  "run `behold demo carve --live` (needs docker + terraform)"
6781
7064
  );
6782
7065
  }
6783
- if (!existsSync8(join13(demo.out, "src"))) {
7066
+ if (!existsSync8(join14(demo.out, "src"))) {
6784
7067
  return refuse3("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
6785
7068
  }
6786
7069
  const args = ["lifecycle", "diff", "prod", "--live", "--json"];
@@ -6850,8 +7133,8 @@ var refuse3 = (code, error, remedy) => ({
6850
7133
  });
6851
7134
  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.";
6852
7135
  function insideDemo(root, p) {
6853
- const rel = relative2(resolve4(root), resolve4(p));
6854
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
7136
+ const rel = relative3(resolve5(root), resolve5(p));
7137
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
6855
7138
  }
6856
7139
  function selectFromReport(report, raw) {
6857
7140
  if (typeof raw !== "string" || !raw || raw.length > 512) return null;
@@ -6871,17 +7154,17 @@ function readArtifacts(demo, dir, filter) {
6871
7154
  if (out.length >= MAX_ARTIFACTS || depth > 2) return;
6872
7155
  let entries;
6873
7156
  try {
6874
- entries = readdirSync5(d).sort();
7157
+ entries = readdirSync6(d).sort();
6875
7158
  } catch {
6876
7159
  return;
6877
7160
  }
6878
7161
  for (const name of entries) {
6879
7162
  if (out.length >= MAX_ARTIFACTS) return;
6880
7163
  if (name === "node_modules" || name.startsWith(".")) continue;
6881
- const full = join13(d, name);
7164
+ const full = join14(d, name);
6882
7165
  let st;
6883
7166
  try {
6884
- st = statSync5(full);
7167
+ st = statSync6(full);
6885
7168
  } catch {
6886
7169
  continue;
6887
7170
  }
@@ -6889,7 +7172,7 @@ function readArtifacts(demo, dir, filter) {
6889
7172
  walk(full, depth + 1);
6890
7173
  continue;
6891
7174
  }
6892
- const rel = relative2(demo.root, full).split(sep2).join("/");
7175
+ const rel = relative3(demo.root, full).split(sep2).join("/");
6893
7176
  if (!filter(rel)) continue;
6894
7177
  let text = "";
6895
7178
  try {
@@ -6925,7 +7208,7 @@ async function runCarveEmit(demo, select) {
6925
7208
  const block = carveWriteBlock(demo);
6926
7209
  if (block) return refuse3("read-only", block, "Start the walkthrough with `behold demo carve`.");
6927
7210
  mkdirSync3(demo.out, { recursive: true });
6928
- const reportFile = join13(demo.out, `${carveSlug(select)}-boundary.json`);
7211
+ const reportFile = join14(demo.out, `${carveSlug(select)}-boundary.json`);
6929
7212
  const args = [
6930
7213
  "carve",
6931
7214
  "emit",
@@ -6957,7 +7240,7 @@ async function runCarveEmit(demo, select) {
6957
7240
  boundary = JSON.parse(readFileSync10(reportFile, "utf8"));
6958
7241
  } catch {
6959
7242
  }
6960
- const lintPath = relative2(demo.project, join13(demo.out, "src")).split(sep2).join("/");
7243
+ const lintPath = relative3(demo.project, join14(demo.out, "src")).split(sep2).join("/");
6961
7244
  const lintArgs = ["lint", lintPath];
6962
7245
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
6963
7246
  code: 127,
@@ -7020,8 +7303,8 @@ async function runCarveBridge(demo, select) {
7020
7303
  }
7021
7304
 
7022
7305
  // src/ops.ts
7023
- import { readdirSync as readdirSync6, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
7024
- import { join as join14 } from "node:path";
7306
+ import { readdirSync as readdirSync7, readFileSync as readFileSync11, existsSync as existsSync9 } from "node:fs";
7307
+ import { join as join15 } from "node:path";
7025
7308
  var APPLY_TARGET_LEXICON = {
7026
7309
  cloudformation: "aws",
7027
7310
  kubectl: "k8s",
@@ -7043,11 +7326,11 @@ function discoverOps(projectDir) {
7043
7326
  const seen = /* @__PURE__ */ new Set();
7044
7327
  const out = [];
7045
7328
  for (const sub of ["ops", "src", "."]) {
7046
- const dir = join14(projectDir, sub);
7329
+ const dir = join15(projectDir, sub);
7047
7330
  if (!existsSync9(dir)) continue;
7048
- for (const f of readdirSync6(dir)) {
7331
+ for (const f of readdirSync7(dir)) {
7049
7332
  if (!f.endsWith(".op.ts")) continue;
7050
- const content = readFileSync11(join14(dir, f), "utf8");
7333
+ const content = readFileSync11(join15(dir, f), "utf8");
7051
7334
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
7052
7335
  if (!name || seen.has(name)) continue;
7053
7336
  seen.add(name);
@@ -7070,15 +7353,15 @@ function discoverOps(projectDir) {
7070
7353
  return out.sort((a, b) => a.name.localeCompare(b.name));
7071
7354
  }
7072
7355
  function opIrPath(projectDir, name) {
7073
- return join14(projectDir, "dist", "ops", name, "op.json");
7356
+ return join15(projectDir, "dist", "ops", name, "op.json");
7074
7357
  }
7075
7358
  function discoverOpIrs(projectDirs) {
7076
7359
  const seen = /* @__PURE__ */ new Set();
7077
7360
  const out = [];
7078
7361
  for (const dir of projectDirs) {
7079
- const root = join14(dir, "dist", "ops");
7362
+ const root = join15(dir, "dist", "ops");
7080
7363
  if (!existsSync9(root)) continue;
7081
- for (const name of readdirSync6(root)) {
7364
+ for (const name of readdirSync7(root)) {
7082
7365
  if (seen.has(name)) continue;
7083
7366
  const path = opIrPath(dir, name);
7084
7367
  if (!existsSync9(path)) continue;
@@ -7316,11 +7599,11 @@ var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
7316
7599
  function tookLabel(ms) {
7317
7600
  return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
7318
7601
  }
7319
- function paintPlayhead(ir, join21, gate) {
7320
- if (!join21.byNode.size && !join21.at) return ir;
7602
+ function paintPlayhead(ir, join22, gate) {
7603
+ if (!join22.byNode.size && !join22.at) return ir;
7321
7604
  const nodes = ir.nodes.map((node) => {
7322
- const run3 = join21.byNode.get(node.id);
7323
- const here = join21.at === node.id;
7605
+ const run3 = join22.byNode.get(node.id);
7606
+ const here = join22.at === node.id;
7324
7607
  if (!run3 && !here) return node;
7325
7608
  const isGate = node.attrs?._step === "gate";
7326
7609
  const attrs = { ...node.attrs };
@@ -7335,7 +7618,7 @@ function paintPlayhead(ir, join21, gate) {
7335
7618
  if (here) {
7336
7619
  attrs._playhead = true;
7337
7620
  attrs._status = isGate && gate ? "warn" : "accent";
7338
- attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join21.atReason ?? "here";
7621
+ attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join22.atReason ?? "here";
7339
7622
  if (isGate && gate?.description) attrs.gateDescription = gate.description;
7340
7623
  }
7341
7624
  return { ...node, attrs };
@@ -7357,11 +7640,11 @@ function pendingGateCard(op, gate, ir) {
7357
7640
  }
7358
7641
  };
7359
7642
  }
7360
- function playheadNote(state, join21) {
7643
+ function playheadNote(state, join22) {
7361
7644
  if (!state.op) {
7362
7645
  return "No run state: nothing has been run from this behold session, so every step paints as declared.";
7363
7646
  }
7364
- const settled = join21 ? join21.byNode.size : state.records.length;
7647
+ const settled = join22 ? join22.byNode.size : state.records.length;
7365
7648
  const bits = [];
7366
7649
  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`;
7367
7650
  if (state.status === "running") {
@@ -7377,9 +7660,9 @@ function playheadNote(state, join21) {
7377
7660
  } else if (state.gateNote) {
7378
7661
  bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
7379
7662
  }
7380
- if (join21?.unplaced.length) {
7663
+ if (join22?.unplaced.length) {
7381
7664
  bits.push(
7382
- `${join21.unplaced.length} record${join21.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join21.unplaced.length === 1 ? "is" : "are"} not painted`
7665
+ `${join22.unplaced.length} record${join22.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join22.unplaced.length === 1 ? "is" : "are"} not painted`
7383
7666
  );
7384
7667
  }
7385
7668
  if (!bits.length) return `${head}.`;
@@ -8046,23 +8329,23 @@ var OpRunner = class {
8046
8329
  // src/substrates.ts
8047
8330
  import { spawn as spawn4 } from "node:child_process";
8048
8331
  import { existsSync as existsSync10, readFileSync as readFileSync12 } from "node:fs";
8049
- import { join as join15 } from "node:path";
8332
+ import { join as join16 } from "node:path";
8050
8333
  import { platform } from "node:os";
8051
8334
  var DOCKER_DOWN_DETAIL = "docker is down";
8052
8335
  function probe(cmd, args) {
8053
- return new Promise((resolve9) => {
8336
+ return new Promise((resolve10) => {
8054
8337
  let out = "";
8055
8338
  let proc;
8056
8339
  try {
8057
8340
  proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
8058
8341
  } catch {
8059
- resolve9({ code: 127, out: "" });
8342
+ resolve10({ code: 127, out: "" });
8060
8343
  return;
8061
8344
  }
8062
8345
  proc.stdout.on("data", (d) => out += d);
8063
8346
  proc.stderr.on("data", (d) => out += d);
8064
- proc.on("error", () => resolve9({ code: 127, out }));
8065
- proc.on("close", (code) => resolve9({ code: code ?? 1, out }));
8347
+ proc.on("error", () => resolve10({ code: 127, out }));
8348
+ proc.on("close", (code) => resolve10({ code: code ?? 1, out }));
8066
8349
  });
8067
8350
  }
8068
8351
  async function dockerAvailable(run3) {
@@ -8075,11 +8358,11 @@ async function dockerRunning(run3, nameFilter) {
8075
8358
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
8076
8359
  }
8077
8360
  function scriptBringUp(projectDir, relPath, label) {
8078
- return existsSync10(join15(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
8361
+ return existsSync10(join16(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
8079
8362
  }
8080
8363
  function projectLexicons(projectDir) {
8081
8364
  try {
8082
- const src = readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8");
8365
+ const src = readFileSync12(join16(projectDir, "chant.config.ts"), "utf-8");
8083
8366
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
8084
8367
  if (!m) return [];
8085
8368
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -8131,7 +8414,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
8131
8414
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
8132
8415
  ];
8133
8416
  for (const [name, label, marker, script] of forges) {
8134
- if (!existsSync10(join15(projectDir, marker))) continue;
8417
+ if (!existsSync10(join16(projectDir, marker))) continue;
8135
8418
  const c = docker ? await dockerRunning(run3, name) : [];
8136
8419
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
8137
8420
  subs.push({
@@ -8165,7 +8448,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
8165
8448
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
8166
8449
  });
8167
8450
  }
8168
- if (existsSync10(join15(projectDir, ".github", "workflows"))) {
8451
+ if (existsSync10(join16(projectDir, ".github", "workflows"))) {
8169
8452
  const gh = await run3("gh", ["auth", "status"]);
8170
8453
  const ready = gh.code === 0;
8171
8454
  subs.push({
@@ -8178,7 +8461,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
8178
8461
  if (lexicons.includes("temporal")) {
8179
8462
  let hasProfiles = false;
8180
8463
  try {
8181
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join15(projectDir, "chant.config.ts"), "utf-8"));
8464
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync12(join16(projectDir, "chant.config.ts"), "utf-8"));
8182
8465
  } catch {
8183
8466
  }
8184
8467
  subs.push({
@@ -8282,12 +8565,12 @@ async function openRollbackBranches(projectDir, env) {
8282
8565
  }
8283
8566
 
8284
8567
  // src/estate-edges.ts
8285
- import { statSync as statSync6 } from "node:fs";
8568
+ import { statSync as statSync7 } from "node:fs";
8286
8569
  var rec5 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
8287
8570
  var str5 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
8288
8571
  var realIsDir2 = (p) => {
8289
8572
  try {
8290
- return statSync6(p).isDirectory();
8573
+ return statSync7(p).isDirectory();
8291
8574
  } catch {
8292
8575
  return false;
8293
8576
  }
@@ -8387,7 +8670,7 @@ function addEstateMemberEdges(ir, members, isDir = realIsDir2) {
8387
8670
 
8388
8671
  // src/events.ts
8389
8672
  import { watch, existsSync as existsSync11 } from "node:fs";
8390
- import { join as join16 } from "node:path";
8673
+ import { join as join17 } from "node:path";
8391
8674
  var Broadcaster = class {
8392
8675
  listeners = /* @__PURE__ */ new Set();
8393
8676
  subscribe(fn) {
@@ -8405,7 +8688,7 @@ var Broadcaster = class {
8405
8688
  };
8406
8689
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
8407
8690
  function watchSource(projectDir, onChange, debounceMs = 200) {
8408
- const dir = existsSync11(join16(projectDir, "src")) ? join16(projectDir, "src") : projectDir;
8691
+ const dir = existsSync11(join17(projectDir, "src")) ? join17(projectDir, "src") : projectDir;
8409
8692
  let timer;
8410
8693
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
8411
8694
  const name = typeof file === "string" ? file : "";
@@ -8694,12 +8977,12 @@ async function emulatorDown(projectDir) {
8694
8977
 
8695
8978
  // src/demos.ts
8696
8979
  import { readFileSync as readFileSync13, existsSync as existsSync12, cpSync } from "node:fs";
8697
- import { join as join17, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
8980
+ import { join as join18, relative as relative4, resolve as resolve6, sep as sep3 } from "node:path";
8698
8981
  import { spawn as spawn5, spawnSync } from "node:child_process";
8699
8982
  function loadDemoRegistry(pkgRoot2) {
8700
8983
  let raw;
8701
8984
  try {
8702
- raw = JSON.parse(readFileSync13(join17(pkgRoot2, "demos.json"), "utf8"));
8985
+ raw = JSON.parse(readFileSync13(join18(pkgRoot2, "demos.json"), "utf8"));
8703
8986
  } catch {
8704
8987
  return [];
8705
8988
  }
@@ -8739,22 +9022,22 @@ function fetchesFromNetwork(entry) {
8739
9022
  return entry.source === "git";
8740
9023
  }
8741
9024
  function demoTargetDir(entry, cwd = process.cwd()) {
8742
- const legacy = resolve5(cwd, "behold-demo");
9025
+ const legacy = resolve6(cwd, "behold-demo");
8743
9026
  if (entry.name === "writes" && existsSync12(legacy)) return legacy;
8744
- return resolve5(cwd, "behold-demos", entry.name);
9027
+ return resolve6(cwd, "behold-demos", entry.name);
8745
9028
  }
8746
9029
  async function loadDemo(entry, opts) {
8747
9030
  const { pkgRoot: pkgRoot2, target } = opts;
8748
9031
  const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
8749
9032
  if (!existsSync12(target)) {
8750
9033
  if (entry.source === "bundled") {
8751
- const bundled = join17(pkgRoot2, entry.dir);
9034
+ const bundled = join18(pkgRoot2, entry.dir);
8752
9035
  if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
8753
9036
  say(`copying to ${target} (it's yours \u2014 edit it)`);
8754
9037
  try {
8755
9038
  cpSync(bundled, target, {
8756
9039
  recursive: true,
8757
- filter: (src) => !relative3(bundled, src).split(sep3).includes("node_modules")
9040
+ filter: (src) => !relative4(bundled, src).split(sep3).includes("node_modules")
8758
9041
  });
8759
9042
  } catch (err) {
8760
9043
  return { ok: false, error: `copy failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -8768,7 +9051,7 @@ async function loadDemo(entry, opts) {
8768
9051
  } else {
8769
9052
  say(`reusing ${target}`);
8770
9053
  }
8771
- if (existsSync12(join17(target, "package.json")) && !existsSync12(join17(target, "node_modules"))) {
9054
+ if (existsSync12(join18(target, "package.json")) && !existsSync12(join18(target, "node_modules"))) {
8772
9055
  say("npm install\u2026");
8773
9056
  if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
8774
9057
  return { ok: false, error: `npm install failed in ${target}` };
@@ -8780,7 +9063,7 @@ async function loadDemo(entry, opts) {
8780
9063
  return { ok: false, error: `setup failed (${entry.setup})` };
8781
9064
  }
8782
9065
  }
8783
- return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join17(target, d)) : [target] };
9066
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join18(target, d)) : [target] };
8784
9067
  }
8785
9068
  function runStep(cmd, args, opts = {}) {
8786
9069
  return new Promise((res) => {
@@ -8791,8 +9074,8 @@ function runStep(cmd, args, opts = {}) {
8791
9074
  }
8792
9075
 
8793
9076
  // src/server.ts
8794
- var pkgRoot = join18(dirname5(fileURLToPath3(import.meta.url)), "..");
8795
- var webRoot = join18(pkgRoot, "web");
9077
+ var pkgRoot = join19(dirname5(fileURLToPath3(import.meta.url)), "..");
9078
+ var webRoot = join19(pkgRoot, "web");
8796
9079
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
8797
9080
  function optsFromQuery(url, tierEnvVar, projectDir) {
8798
9081
  const q = url.searchParams;
@@ -8842,7 +9125,7 @@ function tierFailure(tier, message) {
8842
9125
  function beholdVersion() {
8843
9126
  try {
8844
9127
  const here = dirname5(fileURLToPath3(import.meta.url));
8845
- return JSON.parse(readFileSync14(join18(here, "..", "package.json"), "utf8")).version ?? "unknown";
9128
+ return JSON.parse(readFileSync14(join19(here, "..", "package.json"), "utf8")).version ?? "unknown";
8846
9129
  } catch {
8847
9130
  return "unknown";
8848
9131
  }
@@ -8890,6 +9173,11 @@ function carveRoutes(app, reportPath, demo) {
8890
9173
  const load = () => readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
8891
9174
  const carveDirs = [dirname5(reportPath), ...demo ? [demo.out] : []];
8892
9175
  const carveStates = () => readCarveStates(carveDirs, nodeCarveIo, 0);
9176
+ const emittedNodes = async (states) => {
9177
+ if (!demo || states.size === 0) return /* @__PURE__ */ new Map();
9178
+ const outIr = await memberIr(demo.out).catch(() => void 0);
9179
+ return emittedNodesFor(outIr, states.values(), demo.out);
9180
+ };
8893
9181
  const demoBlock = () => carveWriteBlock(demo);
8894
9182
  const demoInfo = () => {
8895
9183
  if (!demo) return null;
@@ -8901,8 +9189,8 @@ function carveRoutes(app, reportPath, demo) {
8901
9189
  project: demo.project,
8902
9190
  out: demo.out,
8903
9191
  // Paths the UI can print without leaking the operator's whole home dir.
8904
- outLabel: relative4(demo.root, demo.out).split(sep4).join("/"),
8905
- fromLabel: relative4(demo.root, demo.from).split(sep4).join("/"),
9192
+ outLabel: relative5(demo.root, demo.out).split(sep4).join("/"),
9193
+ fromLabel: relative5(demo.root, demo.from).split(sep4).join("/"),
8906
9194
  runnable: !block,
8907
9195
  ...block ? { reason: block } : {},
8908
9196
  // The live tier, when this boot armed it: the scratch endpoint the
@@ -8919,7 +9207,7 @@ function carveRoutes(app, reportPath, demo) {
8919
9207
  let appGraph;
8920
9208
  const appGraphOnce = () => {
8921
9209
  if (!demo) return Promise.resolve(null);
8922
- appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative4(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
9210
+ appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative5(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
8923
9211
  return appGraph;
8924
9212
  };
8925
9213
  app.get("/api/graph", async (c) => {
@@ -8928,16 +9216,18 @@ function carveRoutes(app, reportPath, demo) {
8928
9216
  const tfIr = carveReportToIr(parsed.report);
8929
9217
  const appSide = await appGraphOnce();
8930
9218
  const states = carveStates();
9219
+ const emitted = await emittedNodes(states);
8931
9220
  const shorten = demo ? (p) => shortenIn(p, demo.root) : (p) => p;
8932
9221
  const single = () => {
8933
- const split = splitCarveState(tfIr, states, { shorten });
9222
+ const split = splitCarveState(tfIr, states, { shorten, emitted });
8934
9223
  return bandGraduated(split.tf, split.graduated);
8935
9224
  };
8936
9225
  const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
8937
- tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
9226
+ tfTitle: `${relative5(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8938
9227
  appTitle: `${appSide.label} \u2014 chant`,
8939
9228
  carved: states,
8940
- shorten
9229
+ shorten,
9230
+ emitted
8941
9231
  }) : (() => {
8942
9232
  const banded = single();
8943
9233
  return { ...renderBanded(banded), ir: banded };
@@ -8985,7 +9275,7 @@ function carveRoutes(app, reportPath, demo) {
8985
9275
  }
8986
9276
  const tfIr = carveReportToIr(parsed.report);
8987
9277
  const html = renderCarveMorph(tfIr, appSide.ir, [select], {
8988
- tfTitle: `${relative4(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
9278
+ tfTitle: `${relative5(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
8989
9279
  appTitle: `${appSide.label} \u2014 chant`,
8990
9280
  title: `carve \u2014 ${select}`,
8991
9281
  carved: carveStates(),
@@ -8993,7 +9283,7 @@ function carveRoutes(app, reportPath, demo) {
8993
9283
  });
8994
9284
  return c.html(html);
8995
9285
  });
8996
- app.get("/api/project", (c) => {
9286
+ app.get("/api/project", async (c) => {
8997
9287
  const parsed = load();
8998
9288
  const total = parsed.ok ? parsed.report.count ?? parsed.report.resources.length : null;
8999
9289
  const state = carveStatePayload(
@@ -9088,6 +9378,20 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9088
9378
  if (cfg.carveReport) carveRoutes(app, cfg.carveReport, cfg.carveDemo);
9089
9379
  let beholdConfig = loadBeholdConfig(cfg.projectDir);
9090
9380
  let tierEnvVar = beholdConfig.tiers?.envVar;
9381
+ const executorFor = (env) => env ? beholdConfig.executor?.[env] : void 0;
9382
+ const executorStatus = (env, d, pipeline) => {
9383
+ if ("invalid" in d) return { ok: false, reason: d.invalid };
9384
+ if (d.forge !== "github") {
9385
+ return { forge: d.forge, workflow: d.workflow, ok: false, reason: `env ${env} deploys through ${d.forge} per .behold.json, and behold has no ${d.forge} trigger (only GitHub Actions, via your gh) \u2014 Deploy stays disabled here; run the pipeline on the forge` };
9386
+ }
9387
+ const r = resolveWorkflow(cfg.projectDir, pipeline ?? { stages: [], jobs: [] }, d.workflow);
9388
+ return "workflow" in r ? { forge: d.forge, workflow: d.workflow, ok: true } : { forge: d.forge, workflow: d.workflow, ok: false, reason: r.reason };
9389
+ };
9390
+ const forgeRefusal = (env, d, what) => ({
9391
+ error: `${what} is refused: env ${env} deploys through ${"invalid" in d ? "a forge" : `${d.forge} (${d.workflow})`} per .behold.json's executor block \u2014 behold never runs it from this machine`,
9392
+ code: "executor-forge",
9393
+ remedy: "invalid" in d ? `Fix .behold.json: ${d.invalid}` : `Use Deploy (POST /api/ci/dispatch?env=${env}), which dispatches the designated workflow through your gh login.`
9394
+ });
9091
9395
  const boundK8sContext = async (env) => {
9092
9396
  try {
9093
9397
  const { lexicons, k8sProfiles } = await detectProject(cfg.projectDir);
@@ -9149,6 +9453,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9149
9453
  if (!info) {
9150
9454
  return c.json({ error: `no Op named "${name}" in the estate` }, 404);
9151
9455
  }
9456
+ const designatedEnv = info.kind === "apply" ? executorFor(info.env) : void 0;
9457
+ if (designatedEnv && info.env) return c.json(forgeRefusal(info.env, designatedEnv, `running ${name}`), 409);
9152
9458
  if (!runner.trigger(name, info.env, info.dir, Boolean(info.gate))) {
9153
9459
  return c.json({ error: `an Op is already running (${runner.running})` }, 409);
9154
9460
  }
@@ -9180,8 +9486,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9180
9486
  return c.json({ started: true, name, ran: label });
9181
9487
  });
9182
9488
  app.post("/api/local/reset", (c) => {
9183
- const down = join18(cfg.projectDir, "scripts/local/local-down.sh");
9184
- const up = join18(cfg.projectDir, "scripts/local/local-up.sh");
9489
+ const down = join19(cfg.projectDir, "scripts/local/local-down.sh");
9490
+ const up = join19(cfg.projectDir, "scripts/local/local-up.sh");
9185
9491
  if (!existsSync13(down) || !existsSync13(up)) {
9186
9492
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
9187
9493
  }
@@ -9318,7 +9624,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9318
9624
  const k8sTarget = lexicons.includes("k8s") ? resolveK8sTarget(k8sProfiles, cfg.env, await loadKubeconfig()) : void 0;
9319
9625
  const axes = deployAxes(tierEnvVar, lexicons, k8sTarget);
9320
9626
  const emittedOps = discoverOpIrs(estateDirs).length;
9321
- const carveState = carveStatePayload(readCarveStates(estateDirs));
9627
+ const carveState = carveStatePayload(await carveStatesUnder(estateDirs));
9322
9628
  return c.json({
9323
9629
  projectDir: cfg.projectDir,
9324
9630
  ...carveState.manifests ? { carve: { state: carveState } } : {},
@@ -9345,6 +9651,10 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9345
9651
  // no `tiers` key) → no `tiers` field → the SPA's picker doesn't render
9346
9652
  // and the graph loads with no tier selected (web/app.js `initPickers`).
9347
9653
  ...beholdConfig.tiers ? { tiers: beholdConfig.tiers.values } : {},
9654
+ // The executor contract (#165): per designated env, whether Deploy can
9655
+ // dispatch right now and, if not, why — so the SPA disables the gesture
9656
+ // with the reason rather than offering a control that 4xxs.
9657
+ ...beholdConfig.executor ? { executor: Object.fromEntries(Object.entries(beholdConfig.executor).map(([env, d]) => [env, executorStatus(env, d)])) } : {},
9348
9658
  // The stack picker's options (#76, follow-up to #71): gated exactly like
9349
9659
  // `tiers` above — only present when `chant.config.ts` declares `stacks[]`
9350
9660
  // at all, so a single-stack/sourceDir-only/legacy project's SPA renders no
@@ -9369,9 +9679,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9369
9679
  app.post("/api/project/open", async (c) => {
9370
9680
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
9371
9681
  const body = await c.req.json().catch(() => ({}));
9372
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : "";
9682
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve7(body.dir.trim()) : "";
9373
9683
  if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
9374
- if (!existsSync13(join18(dir, "chant.config.ts"))) {
9684
+ if (!existsSync13(join19(dir, "chant.config.ts"))) {
9375
9685
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
9376
9686
  }
9377
9687
  switchServedProject([dir]);
@@ -9391,7 +9701,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9391
9701
  };
9392
9702
  app.post("/api/project/reveal", async (c) => {
9393
9703
  const body = await c.req.json().catch(() => ({}));
9394
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve6(body.dir.trim()) : cfg.projectDir;
9704
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve7(body.dir.trim()) : cfg.projectDir;
9395
9705
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
9396
9706
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
9397
9707
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -9459,7 +9769,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9459
9769
  const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
9460
9770
  if (!loaded.ok) return c.json({ error: loaded.error }, 500);
9461
9771
  const dirs = loaded.serveDirs;
9462
- if (!existsSync13(join18(dirs[0], "chant.config.ts"))) {
9772
+ if (!existsSync13(join19(dirs[0], "chant.config.ts"))) {
9463
9773
  return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
9464
9774
  }
9465
9775
  if (entry.serve.local) {
@@ -9557,7 +9867,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9557
9867
  path: "/api/demos/open",
9558
9868
  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)"
9559
9869
  },
9560
- { 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" },
9870
+ { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} \u2014 params: detail=0..3, components=1, logical=1, ops=1 (+entities=1 to link steps to the estate), env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" },
9561
9871
  ...cfg.carveReport ? [
9562
9872
  { method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" },
9563
9873
  {
@@ -9594,7 +9904,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9594
9904
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
9595
9905
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
9596
9906
  { method: "POST", path: "/api/local/reset", desc: "reset the local emulator" },
9597
- { method: "POST", path: "/api/ci/dispatch", desc: "dispatch the GitHub Actions pipeline via the operator's gh" }
9907
+ { method: "POST", path: "/api/ci/dispatch", desc: "dispatch the GitHub Actions pipeline for ?env= via the operator's gh \u2014 the designated workflow when .behold.json names one" }
9598
9908
  ]
9599
9909
  })
9600
9910
  );
@@ -9621,10 +9931,16 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9621
9931
  parsedOps.push(parsed.ir);
9622
9932
  }
9623
9933
  const declaredIr = opsToIr(parsedOps);
9934
+ if (url.searchParams.get("entities") === "1") {
9935
+ const memberDirs = [...new Set(files.map((f) => f.dir))];
9936
+ const memberReads = await Promise.allSettled(memberDirs.map((dir) => memberIr(dir)));
9937
+ const estateNodes = memberReads.flatMap((r) => r.status === "fulfilled" ? r.value.nodes : []);
9938
+ joinStepEntities(declaredIr, estateNodes.length ? { nodes: estateNodes, edges: [], groups: {} } : void 0);
9939
+ }
9624
9940
  const runState = runner.runState;
9625
9941
  const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
9626
- const join21 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
9627
- const opsIr = join21 ? paintPlayhead(declaredIr, join21, runState.gate) : declaredIr;
9942
+ const join22 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
9943
+ const opsIr = join22 ? paintPlayhead(declaredIr, join22, runState.gate) : declaredIr;
9628
9944
  const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
9629
9945
  const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
9630
9946
  return c.json({
@@ -9637,7 +9953,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9637
9953
  target: null,
9638
9954
  mode: "ops",
9639
9955
  ops: parsedOps.length,
9640
- note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join21)} ${operatorNote(operator)}`,
9956
+ note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join22)} ${operatorNote(operator)}`,
9641
9957
  // The operator strip's own model: the ConvergeOps this project
9642
9958
  // declares, plus whatever the last status read said. `declared`
9643
9959
  // without a `strip` is the honest starting state — a loop exists and
@@ -9709,6 +10025,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9709
10025
  return c.json({ ir: projected, svg: svg2, byContainer, meta: { projectDir: cfg.projectDir, env: metaEnv, tier: opts.tier ?? null, target: opts.target ?? null, mode: "logical", ...logicalNote ? { note: logicalNote } : {} } });
9710
10026
  } else {
9711
10027
  ir = mergeClusterRoot(await graphIr(cfg.projectDir, opts), await clusterRootGraphIr(cfg.projectDir, opts));
10028
+ ir = joinCarvedSources(ir, (await carveStatesFor(cfg.projectDir)).values(), cfg.projectDir).ir;
9712
10029
  if ((opts.detail ?? 2) < 3) ir = pruneImports(ir);
9713
10030
  ir = addK8sDeclaredEdges(ir);
9714
10031
  ir = addValueMatchEdges(ir);
@@ -9726,7 +10043,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9726
10043
  srcCompositeEdgesAttached = 0;
9727
10044
  }
9728
10045
  }
9729
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join18(cfg.projectDir, "chant.config.ts"))) {
10046
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join19(cfg.projectDir, "chant.config.ts"))) {
9730
10047
  return c.json(noProjectError(cfg.projectDir), 404);
9731
10048
  }
9732
10049
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
@@ -9773,23 +10090,28 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9773
10090
  }
9774
10091
  });
9775
10092
  app.post("/api/ci/dispatch", async (c) => {
10093
+ const env = new URL(c.req.url).searchParams.get("env") ?? cfg.env;
10094
+ const designated = executorFor(env);
10095
+ if (designated && "invalid" in designated) return c.json({ error: designated.invalid, code: "executor-forge", remedy: "Fix .behold.json's executor block." }, 400);
10096
+ if (designated && designated.forge !== "github") {
10097
+ return c.json({ error: executorStatus(env, designated).reason, code: "executor-forge" }, 400);
10098
+ }
9776
10099
  const ready = await ghReady();
9777
10100
  if (!ready.ok) return c.json({ error: ready.reason }, 400);
9778
- const pipeline = await ciPipeline(cfg.projectDir, cfg.env ? { env: cfg.env } : {}, "github").catch(() => void 0);
10101
+ const pipeline = await ciPipeline(cfg.projectDir, env ? { env } : {}, "github").catch(() => void 0);
9779
10102
  if (!pipeline || pipeline.jobs.length === 0) {
9780
10103
  return c.json({ error: "no generated GitHub pipeline \u2014 `chant build --components --generate github` produced no jobs" }, 400);
9781
10104
  }
9782
- const workflow = pickWorkflow(cfg.projectDir, pipeline);
9783
- if (!workflow) {
9784
- return c.json({ error: "no committed workflow_dispatch workflow whose jobs match the generated pipeline \u2014 commit one (or add `workflow_dispatch:` to its `on:` block)" }, 400);
9785
- }
10105
+ const resolved = resolveWorkflow(cfg.projectDir, pipeline, designated?.workflow);
10106
+ if ("reason" in resolved) return c.json({ error: resolved.reason, ...designated ? { code: "executor-forge" } : {} }, 400);
10107
+ const workflow = resolved.workflow;
9786
10108
  const ref = await execFileP("git", ["-C", cfg.projectDir, "branch", "--show-current"]).then((out) => out.trim() || "main").catch(() => "main");
9787
10109
  const started = runner.track(
9788
10110
  `GitHub Actions ${workflow.file}`,
9789
10111
  (io) => dispatchAndFollow(workflow, pipeline, ref, { onLine: io.line, onProgress: io.progress })
9790
10112
  );
9791
10113
  if (!started) return c.json({ error: `busy \u2014 ${runner.running} is running` }, 409);
9792
- return c.json({ started: true, workflow: workflow.file, ref, jobs: pipeline.jobs.length });
10114
+ return c.json({ started: true, workflow: workflow.file, ref, jobs: pipeline.jobs.length, ...env ? { env } : {}, designated: Boolean(designated) });
9793
10115
  });
9794
10116
  app.get("/api/resources", async (c) => {
9795
10117
  const opts = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
@@ -9884,7 +10206,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9884
10206
  });
9885
10207
  }
9886
10208
  const opts = { ...query, live: true, overlay: true, env, ...logical || runtime ? { detail: 3 } : {} };
9887
- let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
10209
+ let ir = joinCarvedSources(reclassifyOverlay(await graphIr(cfg.projectDir, opts)), (await carveStatesFor(cfg.projectDir)).values(), cfg.projectDir).ir;
9888
10210
  const boundContext = await boundK8sContext(env);
9889
10211
  ir = mergeClusterRoot(ir, await clusterRootGraphIr(cfg.projectDir, query), await runningK3dClusters());
9890
10212
  const declaresHelm = await detectProject(cfg.projectDir).then((p) => p.lexicons.includes("helm")).catch(() => false);
@@ -9892,7 +10214,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9892
10214
  const observed = await lifecycleDiffLive(cfg.projectDir, env, query).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
9893
10215
  applyHelmArtifacts(ir, observed);
9894
10216
  synthesizeHelmReleases(ir, observed, discoverReleaseUnits(cfg.projectDir));
9895
- applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query));
10217
+ applyHelmRenderDrift(ir, await readHelmRenderDrift(cfg.projectDir, env, query, observed));
9896
10218
  }
9897
10219
  ir = runtime ? attachRuntimeContainment(ir) : pruneRuntimeChildren(ir);
9898
10220
  if (logical) {
@@ -9943,7 +10265,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
9943
10265
  const observed = await lifecycleDiffLive(cfg.projectDir, env).then((d) => d.lexicons?.helm?.observedArtifacts).catch(() => void 0);
9944
10266
  applyHelmArtifacts(result.ir, observed);
9945
10267
  synthesizeHelmReleases(result.ir, observed, discoverReleaseUnits(cfg.projectDir));
9946
- applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env));
10268
+ applyHelmRenderDrift(result.ir, await readHelmRenderDrift(cfg.projectDir, env, {}, observed));
9947
10269
  }
9948
10270
  }
9949
10271
  if (env) {
@@ -10047,6 +10369,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
10047
10369
  }
10048
10370
  const component = url.searchParams.get("component") || "all";
10049
10371
  const force = url.searchParams.get("force") === "1";
10372
+ const designated = executorFor(env);
10373
+ if (designated) return c.json(forgeRefusal(env, designated, "apply"), 409);
10050
10374
  const flociTargeted = !!process.env.AWS_ENDPOINT_URL && await detectProject(cfg.projectDir).then((p) => p.lexicons.includes("aws")).catch(() => false);
10051
10375
  if (!force && flociTargeted) {
10052
10376
  try {
@@ -10071,9 +10395,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
10071
10395
  }
10072
10396
  return c.json({ started: true, component, env });
10073
10397
  });
10074
- const rel = relative4(process.cwd(), webRoot) || ".";
10398
+ const rel = relative5(process.cwd(), webRoot) || ".";
10075
10399
  app.use("/*", serveStatic({ root: rel }));
10076
- app.get("/", serveStatic({ path: join18(rel, "index.html") }));
10400
+ app.get("/", serveStatic({ path: join19(rel, "index.html") }));
10077
10401
  return app;
10078
10402
  }
10079
10403
  async function bootLocalEmulators(dir, who) {
@@ -10102,7 +10426,7 @@ async function bootLocalEmulators(dir, who) {
10102
10426
  async function startServer(cfg) {
10103
10427
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
10104
10428
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
10105
- if (existsSync13(join18(dir, "chant.config.ts"))) addRecent(dir);
10429
+ if (existsSync13(join19(dir, "chant.config.ts"))) addRecent(dir);
10106
10430
  }
10107
10431
  const broadcaster = new Broadcaster();
10108
10432
  const frames = new FrameBuffer();
@@ -10128,11 +10452,19 @@ async function startServer(cfg) {
10128
10452
  if (autoSync === "off") return;
10129
10453
  void routeAutoSync(dir, movedLexicons);
10130
10454
  };
10455
+ const executorDesignations = loadBeholdConfig(cfg.projectDir).executor ?? {};
10456
+ const executorFor = (env) => env ? executorDesignations[env] : void 0;
10131
10457
  const routeAutoSync = async (dir, movedLexicons) => {
10132
10458
  const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(dir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
10459
+ const allOps = discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]);
10460
+ const routed = allOps.filter((o) => o.kind === "apply" && o.env && executorFor(o.env));
10461
+ for (const o of routed) {
10462
+ const d = executorFor(o.env);
10463
+ broadcaster.emit("op", `\u27F3 auto-sync (${autoSync})${memberTag(dir)} declined ${o.name}: env ${o.env} deploys through ${"invalid" in d ? "a forge" : d.forge} per .behold.json \u2014 never applied from here`);
10464
+ }
10133
10465
  const { picks, declined } = pickAutoSyncOps(
10134
10466
  autoSync,
10135
- discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]),
10467
+ allOps.filter((o) => !routed.includes(o)),
10136
10468
  runner.running,
10137
10469
  movedLexicons,
10138
10470
  suspended
@@ -10241,8 +10573,8 @@ async function startServer(cfg) {
10241
10573
  }
10242
10574
 
10243
10575
  // src/export.ts
10244
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "node:fs";
10245
- import { join as join19, dirname as dirname6, basename as basename2 } from "node:path";
10576
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync15, readdirSync as readdirSync8 } from "node:fs";
10577
+ import { join as join20, dirname as dirname6, basename as basename2 } from "node:path";
10246
10578
  import { fileURLToPath as fileURLToPath4 } from "node:url";
10247
10579
  var LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "tier"];
10248
10580
  function canonicalKey(path, params) {
@@ -10260,7 +10592,7 @@ function captureKeys(axes) {
10260
10592
  add("/api/project", {});
10261
10593
  add("/api/substrates", {});
10262
10594
  add("/api/ops", {});
10263
- if (axes.ops) add("/api/graph", { ops: "1" });
10595
+ if (axes.ops) add("/api/graph", { ops: "1", entities: "1" });
10264
10596
  const tiers = axes.tiers && axes.tiers.length ? axes.tiers : [""];
10265
10597
  const envs = ["", ...axes.environments];
10266
10598
  for (const env of envs) {
@@ -10291,7 +10623,7 @@ function captureKeys(axes) {
10291
10623
  return [...keys];
10292
10624
  }
10293
10625
  function webDir() {
10294
- return join19(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
10626
+ return join20(dirname6(fileURLToPath4(import.meta.url)), "..", "web");
10295
10627
  }
10296
10628
  function workerName(project, override) {
10297
10629
  const raw = override ?? `behold-${basename2(project)}`;
@@ -10302,7 +10634,7 @@ async function runExport(cfg, outDir, opts = {}) {
10302
10634
  const app = createApp({ ...cfg, layoutWrites: false });
10303
10635
  const proj = await (await app.request("/api/project")).json();
10304
10636
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
10305
- const snapDir = join19(outDir, "snapshots");
10637
+ const snapDir = join20(outDir, "snapshots");
10306
10638
  mkdirSync4(snapDir, { recursive: true });
10307
10639
  const keyToFile = {};
10308
10640
  let ok = 0;
@@ -10311,7 +10643,7 @@ async function runExport(cfg, outDir, opts = {}) {
10311
10643
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
10312
10644
  const body = await res.text();
10313
10645
  const file = slug2(key);
10314
- writeFileSync4(join19(snapDir, file), body);
10646
+ writeFileSync4(join20(snapDir, file), body);
10315
10647
  keyToFile[key] = `snapshots/${file}`;
10316
10648
  if (res.ok) ok++;
10317
10649
  else failed++;
@@ -10323,21 +10655,21 @@ async function runExport(cfg, outDir, opts = {}) {
10323
10655
  axes,
10324
10656
  keyToFile
10325
10657
  };
10326
- writeFileSync4(join19(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
10327
- const html = readFileSync15(join19(webDir(), "index.html"), "utf8").replace(
10658
+ writeFileSync4(join20(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
10659
+ const html = readFileSync15(join20(webDir(), "index.html"), "utf8").replace(
10328
10660
  /<\/head>/i,
10329
10661
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
10330
10662
  </head>`
10331
10663
  );
10332
- writeFileSync4(join19(outDir, "index.html"), html);
10333
- for (const f of readdirSync7(webDir())) {
10664
+ writeFileSync4(join20(outDir, "index.html"), html);
10665
+ for (const f of readdirSync8(webDir())) {
10334
10666
  if (f === "index.html") continue;
10335
- copyFileSync(join19(webDir(), f), join19(outDir, f));
10667
+ copyFileSync(join20(webDir(), f), join20(outDir, f));
10336
10668
  }
10337
- writeFileSync4(join19(outDir, "README.md"), BUNDLE_README);
10669
+ writeFileSync4(join20(outDir, "README.md"), BUNDLE_README);
10338
10670
  const name = workerName(cfg.projectDir, opts.name);
10339
10671
  writeFileSync4(
10340
- join19(outDir, "wrangler.jsonc"),
10672
+ join20(outDir, "wrangler.jsonc"),
10341
10673
  JSON.stringify(
10342
10674
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
10343
10675
  null,
@@ -10385,7 +10717,7 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
10385
10717
  `;
10386
10718
 
10387
10719
  // src/doctor.ts
10388
- import { relative as relative5, resolve as resolve7 } from "node:path";
10720
+ import { relative as relative6, resolve as resolve8 } from "node:path";
10389
10721
  var list2 = (xs) => xs.join(", ");
10390
10722
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
10391
10723
  function summarize(xs, max = 4) {
@@ -10393,7 +10725,7 @@ function summarize(xs, max = 4) {
10393
10725
  }
10394
10726
  function labelFor(root, target, estate) {
10395
10727
  if (!estate) return "";
10396
- const rel = relative5(root, target);
10728
+ const rel = relative6(root, target);
10397
10729
  return `${rel || "."}: `;
10398
10730
  }
10399
10731
  function chantCheck(root, targets, estate) {
@@ -10406,7 +10738,7 @@ function chantCheck(root, targets, estate) {
10406
10738
  name: "chant",
10407
10739
  status: "fail",
10408
10740
  detail: `${list2(resolutions.map(describe))} \u2014 behold shells the project's own chant`,
10409
- fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative5(root, target) || target))}`
10741
+ fix: `Run \`npm install\` in ${list2(missing.map(({ target }) => relative6(root, target) || target))}`
10410
10742
  };
10411
10743
  }
10412
10744
  const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor));
@@ -10534,7 +10866,7 @@ function opsCheck(root, ops, chantSource, estate) {
10534
10866
  return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list2(names)}; ${mcp}` };
10535
10867
  }
10536
10868
  async function diagnose(dir, probes = {}) {
10537
- const root = resolve7(dir);
10869
+ const root = resolve8(dir);
10538
10870
  const shape = detectProjectShape(root);
10539
10871
  const behold = beholdVersion();
10540
10872
  if (shape.kind === "none") {
@@ -10554,16 +10886,16 @@ async function diagnose(dir, probes = {}) {
10554
10886
  };
10555
10887
  }
10556
10888
  const estate = shape.kind === "estate";
10557
- const members = (shape.members ?? []).map((m) => resolve7(root, m));
10889
+ const members = (shape.members ?? []).map((m) => resolve8(root, m));
10558
10890
  const targets = estate ? members : [root];
10559
10891
  const primary = targets[0];
10560
10892
  const projectCheck = estate ? {
10561
10893
  name: "project",
10562
10894
  status: "pass",
10563
10895
  detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list2(
10564
- members.map((m) => relative5(root, m))
10896
+ members.map((m) => relative6(root, m))
10565
10897
  )}`
10566
- } : { name: "project", status: "pass", detail: `chant project (${relative5(root, shape.configFile)})` };
10898
+ } : { name: "project", status: "pass", detail: `chant project (${relative6(root, shape.configFile)})` };
10567
10899
  const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) })));
10568
10900
  const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons]));
10569
10901
  const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))];
@@ -10788,7 +11120,7 @@ ${USAGE}`);
10788
11120
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
10789
11121
  process.exit(2);
10790
11122
  }
10791
- const dirs = projectDirs.map((d) => resolve8(d));
11123
+ const dirs = projectDirs.map((d) => resolve9(d));
10792
11124
  for (const d of dirs) warnIfNotChantProject(d);
10793
11125
  await startServer({
10794
11126
  projectDir: dirs[0],
@@ -10807,7 +11139,7 @@ function warnIfNotChantProject(dir) {
10807
11139
  if (shape.kind === "estate") {
10808
11140
  process.stderr.write(
10809
11141
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
10810
- Serve its members composed: behold serve ${shape.members.map((m) => join20(dir, m)).join(" ")}
11142
+ Serve its members composed: behold serve ${shape.members.map((m) => join21(dir, m)).join(" ")}
10811
11143
  `
10812
11144
  );
10813
11145
  return;
@@ -10842,7 +11174,7 @@ async function runCarve(rest) {
10842
11174
  );
10843
11175
  process.exit(2);
10844
11176
  }
10845
- const reportPath = resolve8(fileArg);
11177
+ const reportPath = resolve9(fileArg);
10846
11178
  const parsed = readCarveReport(reportPath, (p) => readFileSync16(p, "utf8"));
10847
11179
  if (!parsed.ok) {
10848
11180
  process.stderr.write(`behold carve: ${parsed.refusal.error}
@@ -10870,8 +11202,8 @@ async function runDoctor(rest) {
10870
11202
  }
10871
11203
  }
10872
11204
  const dir = dirArg ?? ".";
10873
- if (!existsSync14(resolve8(dir))) {
10874
- process.stderr.write(`behold doctor: no such directory: ${resolve8(dir)}
11205
+ if (!existsSync14(resolve9(dir))) {
11206
+ process.stderr.write(`behold doctor: no such directory: ${resolve9(dir)}
10875
11207
  `);
10876
11208
  process.exit(2);
10877
11209
  }
@@ -10880,7 +11212,7 @@ async function runDoctor(rest) {
10880
11212
  if (!report.ok) process.exitCode = 1;
10881
11213
  }
10882
11214
  async function runDemo(rest) {
10883
- const pkgRoot2 = join20(dirname7(fileURLToPath5(import.meta.url)), "..");
11215
+ const pkgRoot2 = join21(dirname7(fileURLToPath5(import.meta.url)), "..");
10884
11216
  const registry = loadDemoRegistry(pkgRoot2);
10885
11217
  let port = 4600;
10886
11218
  let live = false;
@@ -10934,7 +11266,7 @@ async function runDemo(rest) {
10934
11266
  `);
10935
11267
  process.exit(2);
10936
11268
  }
10937
- const target = dirArg ? resolve8(dirArg) : demoTargetDir(entry);
11269
+ const target = dirArg ? resolve9(dirArg) : demoTargetDir(entry);
10938
11270
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot2, target, log: (line) => process.stdout.write(line + "\n") });
10939
11271
  if (!loaded.ok) {
10940
11272
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -10965,7 +11297,7 @@ function spawnStep(cmd, args, cwd) {
10965
11297
  });
10966
11298
  }
10967
11299
  async function serveCarveDemo(target, carve, port, live = false) {
10968
- const at = (rel) => resolve8(target, rel);
11300
+ const at = (rel) => resolve9(target, rel);
10969
11301
  const project = at(carve.project);
10970
11302
  const from = at(carve.from);
10971
11303
  const state = carve.state ? at(carve.state) : void 0;
@@ -11005,7 +11337,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
11005
11337
  };
11006
11338
  process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
11007
11339
  }
11008
- if (existsSync14(join20(project, "package.json")) && !existsSync14(join20(project, "node_modules"))) {
11340
+ if (existsSync14(join21(project, "package.json")) && !existsSync14(join21(project, "node_modules"))) {
11009
11341
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
11010
11342
  `);
11011
11343
  const code = await spawnStep("npm", ["install"], project);
@@ -11016,7 +11348,7 @@ async function serveCarveDemo(target, carve, port, live = false) {
11016
11348
  }
11017
11349
  }
11018
11350
  let degraded;
11019
- if (!existsSync14(join20(target, "node_modules", "@cdktf", "hcl2json"))) {
11351
+ if (!existsSync14(join21(target, "node_modules", "@cdktf", "hcl2json"))) {
11020
11352
  process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
11021
11353
  const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
11022
11354
  if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
@@ -11085,7 +11417,7 @@ async function runPreview(rest) {
11085
11417
  process.stderr.write("behold preview: --port must be a number\n");
11086
11418
  process.exit(2);
11087
11419
  }
11088
- const projectDir = resolve8(dirArg ?? process.cwd());
11420
+ const projectDir = resolve9(dirArg ?? process.cwd());
11089
11421
  if (!existsSync14(projectDir)) {
11090
11422
  process.stderr.write(`behold preview: project not found at ${projectDir}
11091
11423
  `);
@@ -11106,21 +11438,21 @@ async function runPreview(rest) {
11106
11438
  await startServer({ projectDir, port, env: "local", previewMode: true });
11107
11439
  }
11108
11440
  async function runExportCmd(rest) {
11109
- let outDir = resolve8("behold-export");
11441
+ let outDir = resolve9("behold-export");
11110
11442
  let env;
11111
11443
  let name;
11112
11444
  let dirArg;
11113
11445
  let emulator = false;
11114
11446
  for (let i = 0; i < rest.length; i++) {
11115
11447
  const a = rest[i];
11116
- if (a === "--out") outDir = resolve8(rest[++i]);
11448
+ if (a === "--out") outDir = resolve9(rest[++i]);
11117
11449
  else if (a === "--env") env = rest[++i];
11118
11450
  else if (a === "--name") name = rest[++i];
11119
11451
  else if (a === "--emulator") emulator = true;
11120
11452
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
11121
11453
  else if (!a.startsWith("-")) dirArg = a;
11122
11454
  }
11123
- const projectDir = resolve8(dirArg ?? process.cwd());
11455
+ const projectDir = resolve9(dirArg ?? process.cwd());
11124
11456
  if (emulator) {
11125
11457
  injectEmulatorEnv(env);
11126
11458
  env ??= "local";