@intentius/behold 0.19.0 → 0.21.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.
Files changed (52) hide show
  1. package/AGENTS.md +90 -5
  2. package/README.md +54 -1
  3. package/demos.json +3 -2
  4. package/dist/cli.js +1327 -303
  5. package/example-argo-estate/README.md +8 -8
  6. package/example-argo-estate/app-a/package.json +2 -2
  7. package/example-argo-estate/app-b/package.json +2 -2
  8. package/example-argo-estate/chant.workspace.json +27 -0
  9. package/example-argo-estate/control-plane/package.json +2 -2
  10. package/example-argo-estate/package-lock.json +133 -56
  11. package/example-carve/README.md +14 -14
  12. package/example-carve/app/package-lock.json +72 -9
  13. package/example-carve/app/package.json +2 -2
  14. package/example-carve/legacy-tf/versions.tf +1 -1
  15. package/example-choudoufu-estate/README.md +4 -4
  16. package/example-choudoufu-estate/chant.workspace.json +32 -0
  17. package/example-choudoufu-estate/scripts/choudoufu-up.sh +21 -0
  18. package/example-flux-estate/README.md +5 -5
  19. package/example-flux-estate/app-a/package.json +2 -2
  20. package/example-flux-estate/app-b/package.json +2 -2
  21. package/example-flux-estate/chant.workspace.json +27 -0
  22. package/example-flux-estate/control-plane/package.json +2 -2
  23. package/example-flux-estate/package-lock.json +133 -56
  24. package/example-k8s/README.md +20 -20
  25. package/example-k8s/chant.config.ts +1 -1
  26. package/example-k8s/ops/k3d-apply.op.ts +1 -1
  27. package/example-k8s/package-lock.json +129 -211
  28. package/example-k8s/package.json +2 -3
  29. package/example-terraform-estate/README.md +5 -5
  30. package/example-terraform-estate/chant.workspace.json +22 -0
  31. package/example-writes/README.md +5 -5
  32. package/example-writes/chant.config.ts +1 -1
  33. package/example-writes/ops/apply.op.ts +1 -1
  34. package/example-writes/ops/floci.op.ts +2 -2
  35. package/example-writes/ops/reconcile.op.ts +1 -1
  36. package/example-writes/package-lock.json +62 -175
  37. package/example-writes/package.json +2 -3
  38. package/package.json +6 -6
  39. package/web/app.js +431 -17
  40. package/web/behaviour-scale.js +32 -0
  41. package/web/behaviour-scale.test.js +39 -0
  42. package/web/embed.js +76 -0
  43. package/web/embed.test.js +45 -0
  44. package/web/index.html +20 -0
  45. package/web/operator.js +14 -1
  46. package/web/panel.js +11 -3
  47. package/web/run-playhead.js +13 -1
  48. package/web/view-url.js +138 -0
  49. package/web/view-url.test.js +84 -0
  50. package/web/workspace-gates.js +44 -0
  51. package/web/workspace-gates.test.js +34 -0
  52. package/example-choudoufu-estate/.behold.json +0 -8
package/dist/cli.js CHANGED
@@ -118,17 +118,17 @@ var init_read_scheduler = __esm({
118
118
  recordRefused();
119
119
  return Promise.reject(new Error("Chant read queue is full; retry after current reads finish"));
120
120
  }
121
- let resolve15;
121
+ let resolve19;
122
122
  let reject;
123
123
  const promise = new Promise((yes, no) => {
124
- resolve15 = yes;
124
+ resolve19 = yes;
125
125
  reject = no;
126
126
  });
127
127
  job = {
128
128
  key,
129
129
  run: run3,
130
130
  promise,
131
- resolve: resolve15,
131
+ resolve: resolve19,
132
132
  reject,
133
133
  controller: new AbortController(),
134
134
  users: 0,
@@ -142,7 +142,7 @@ var init_read_scheduler = __esm({
142
142
  }
143
143
  const shared2 = job;
144
144
  shared2.users++;
145
- const result = new Promise((resolve15, reject) => {
145
+ const result = new Promise((resolve19, reject) => {
146
146
  let done = false;
147
147
  const finish = (error, value, failed = false) => {
148
148
  if (done) return;
@@ -150,7 +150,7 @@ var init_read_scheduler = __esm({
150
150
  signal?.removeEventListener("abort", abort);
151
151
  shared2.users--;
152
152
  if (failed) reject(error);
153
- else resolve15(value);
153
+ else resolve19(value);
154
154
  };
155
155
  const abort = () => {
156
156
  finish(signal?.reason, void 0, true);
@@ -1038,11 +1038,11 @@ async function createK8sClient(options = {}) {
1038
1038
  }
1039
1039
  return void 0;
1040
1040
  }
1041
- async function resolve15(selector, signal) {
1041
+ async function resolve19(selector, signal) {
1042
1042
  return "apiVersion" in selector ? resolveByGvk(selector.apiVersion, selector.kind, signal) : resolveByResourceString(selector.resource, selector.group, signal);
1043
1043
  }
1044
1044
  async function resolveOrThrow(selector, signal) {
1045
- const info = await resolve15(selector, signal);
1045
+ const info = await resolve19(selector, signal);
1046
1046
  if (!info) throw new UnknownResourceError(selectorText(selector));
1047
1047
  return info;
1048
1048
  }
@@ -1054,7 +1054,7 @@ async function createK8sClient(options = {}) {
1054
1054
  return parts.join("/");
1055
1055
  }
1056
1056
  async function pathFor(ref, signal) {
1057
- const info = await resolve15({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
1057
+ const info = await resolve19({ apiVersion: ref.apiVersion, kind: ref.kind }, signal);
1058
1058
  if (!info) return void 0;
1059
1059
  return objectPath(info, ref.name, ref.namespace);
1060
1060
  }
@@ -1168,8 +1168,8 @@ async function createK8sClient(options = {}) {
1168
1168
  const { lines } = await openStream(listPath, query, controller.signal, target);
1169
1169
  let expired = false;
1170
1170
  const iterator = lines[Symbol.asyncIterator]();
1171
- const stopped = new Promise((resolve16) => {
1172
- const finish = () => resolve16({ done: true, value: void 0 });
1171
+ const stopped = new Promise((resolve20) => {
1172
+ const finish = () => resolve20({ done: true, value: void 0 });
1173
1173
  if (controller.signal.aborted) finish();
1174
1174
  else controller.signal.addEventListener("abort", finish, { once: true });
1175
1175
  });
@@ -1196,11 +1196,11 @@ async function createK8sClient(options = {}) {
1196
1196
  resourceVersion = await listResourceVersion();
1197
1197
  continue;
1198
1198
  }
1199
- await new Promise((resolve16) => {
1200
- const timer = setTimeout(resolve16, reopenDelayMs);
1199
+ await new Promise((resolve20) => {
1200
+ const timer = setTimeout(resolve20, reopenDelayMs);
1201
1201
  controller.signal.addEventListener("abort", () => {
1202
1202
  clearTimeout(timer);
1203
- resolve16(void 0);
1203
+ resolve20(void 0);
1204
1204
  }, { once: true });
1205
1205
  });
1206
1206
  }
@@ -1318,7 +1318,7 @@ async function createK8sClient(options = {}) {
1318
1318
  return {
1319
1319
  provenance,
1320
1320
  defaultNamespace,
1321
- resolve: resolve15,
1321
+ resolve: resolve19,
1322
1322
  read,
1323
1323
  pathFor,
1324
1324
  readIfPresent,
@@ -1576,8 +1576,8 @@ function parseLiveCheckOutput(run3, dir) {
1576
1576
  if (run3.code === 127) return refuse(`choudoufu is not on PATH (${stripAnsi(run3.stderr).trim() || "spawn failed"}).`, INSTALL);
1577
1577
  const text = run3.stdout.trim();
1578
1578
  if (!text) {
1579
- const said3 = stripAnsiLines(run3.stderr);
1580
- const why = said3.find((l) => /^Error:/.test(l)) ?? said3[0] ?? `exit ${run3.code}`;
1579
+ const said4 = stripAnsiLines(run3.stderr);
1580
+ const why = said4.find((l) => /^Error:/.test(l)) ?? said4[0] ?? `exit ${run3.code}`;
1581
1581
  return refuse(`choudoufu could not read ${dir}: ${why}`, "Fix the configuration until `choudoufu live-check` prints a document, then reload.");
1582
1582
  }
1583
1583
  let json;
@@ -1734,6 +1734,9 @@ function choudoufuVersion(bin = choudoufuBinary()) {
1734
1734
  function setChoudoufuSpawnEnv(env) {
1735
1735
  spawnEnvOverride = env && Object.keys(env).length ? { ...env } : void 0;
1736
1736
  }
1737
+ function choudoufuSpawnOverride() {
1738
+ return spawnEnvOverride ? { ...spawnEnvOverride } : void 0;
1739
+ }
1737
1740
  function choudoufuSpawnEnv(base = process.env) {
1738
1741
  return { ...base, ...spawnEnvOverride ?? {}, NO_COLOR: "1" };
1739
1742
  }
@@ -2312,9 +2315,9 @@ function beholdNodeModules(from = beholdDir()) {
2312
2315
  dir = parent;
2313
2316
  }
2314
2317
  }
2315
- function terraformScratchDir(estate, tmp = tmpdir()) {
2318
+ function terraformScratchDir(estate, tmp = tmpdir(), flavour = "tf") {
2316
2319
  const target = resolve(estate);
2317
- const name = `${SCRATCH_PREFIX}tf-${createHash("sha1").update(target).digest("hex").slice(0, 12)}`;
2320
+ const name = `${SCRATCH_PREFIX}${flavour}-${createHash("sha1").update(target).digest("hex").slice(0, 12)}`;
2318
2321
  assertScratch(name);
2319
2322
  let root = resolve(tmp);
2320
2323
  try {
@@ -2327,7 +2330,7 @@ function terraformScratchDir(estate, tmp = tmpdir()) {
2327
2330
  }
2328
2331
  return dir;
2329
2332
  }
2330
- function terraformScratchConfig(scan, estateLink) {
2333
+ function terraformScratchConfig(scan, estateLink, flavour = "tf") {
2331
2334
  const roots = [...scan.roots].sort((a, b) => a.name < b.name ? -1 : 1);
2332
2335
  const entries = roots.map((r) => ` ${JSON.stringify(r.name)}: { dir: ${JSON.stringify(r.dir === "." ? estateLink : `${estateLink}/${r.dir}`)} },`);
2333
2336
  return [
@@ -2341,6 +2344,7 @@ function terraformScratchConfig(scan, estateLink) {
2341
2344
  "export default {",
2342
2345
  ' lexicons: ["terraform"],',
2343
2346
  " terraform: {",
2347
+ ...flavour === "chdf" ? [' binary: "choudoufu",'] : [],
2344
2348
  " roots: {",
2345
2349
  ...entries,
2346
2350
  " },",
@@ -2357,14 +2361,14 @@ function relink(path, target) {
2357
2361
  }
2358
2362
  symlinkSync(target, path);
2359
2363
  }
2360
- function writeTerraformScratchProject(estate, scan, tmp = tmpdir()) {
2364
+ function writeTerraformScratchProject(estate, scan, tmp = tmpdir(), flavour = "tf") {
2361
2365
  const target = resolve(estate);
2362
- const dir = terraformScratchDir(target, tmp);
2366
+ const dir = terraformScratchDir(target, tmp, flavour);
2363
2367
  mkdirSync2(dir, { recursive: true });
2364
2368
  relink(join3(dir, ESTATE_LINK), target);
2365
2369
  const modules = beholdNodeModules();
2366
2370
  if (modules) relink(join3(dir, "node_modules"), modules);
2367
- const config = terraformScratchConfig(scan, ESTATE_LINK);
2371
+ const config = terraformScratchConfig(scan, ESTATE_LINK, flavour);
2368
2372
  const path = join3(dir, "chant.config.ts");
2369
2373
  if (!existsSync3(path) || readFileSync3(path, "utf8") !== config) writeFileSync2(path, config);
2370
2374
  return dir;
@@ -2398,7 +2402,7 @@ var init_terraform_member = __esm({
2398
2402
  init_terraform_lens();
2399
2403
  init_scratch();
2400
2404
  TERRAFORM_LEXICON_PKG = "@intentius/chant-lexicon-terraform";
2401
- HCL_PARSER_PKG = "@cdktf/hcl2json";
2405
+ HCL_PARSER_PKG = "@cdktn/hcl2json";
2402
2406
  ROOT_SCAN_DEPTH = 3;
2403
2407
  SKIP_DIRS = /* @__PURE__ */ new Set([".terraform", "node_modules", ".git", "dist", ".chant", "cdk.out"]);
2404
2408
  ROOT_BLOCK = /^(terraform\s*\{|provider\s+")/m;
@@ -2636,6 +2640,7 @@ function verdictFor(address, ls, plan) {
2636
2640
  const gap = ls?.gaps?.find((g) => g.address === address);
2637
2641
  const omission = plan?.omissions?.find((o) => o.addr === address);
2638
2642
  const unowned = plan?.unowned?.find((u) => u.addr === address);
2643
+ const adoptable = plan?.adoptable?.find((a) => a.addr === address);
2639
2644
  const listing = gap ? { listing: gap.detail } : {};
2640
2645
  if (bound) {
2641
2646
  const id = bound.identity || item?.id;
@@ -2649,6 +2654,26 @@ function verdictFor(address, ls, plan) {
2649
2654
  if (omission) {
2650
2655
  const tone = REASON_TONE[omission.reason] ?? "neutral";
2651
2656
  const base = { omission: omission.reason, detail: omission.detail, ...listing };
2657
+ if (omission.reason === "NEEDS_DISCOVERY" && adoptable) {
2658
+ const adopt = adoptLine(adoptable);
2659
+ const matched = (adoptable.matched ?? []).filter((m) => m && m.attribute);
2660
+ return {
2661
+ _status: "warn",
2662
+ physicalId: adoptable.identity,
2663
+ ownership: "foreign",
2664
+ attrs: {
2665
+ // The write to run first, as #393 item 10 settled for the unowned
2666
+ // card. behold composes none of its own: `adoptLine` prints
2667
+ // choudoufu's `adopt_command` when there is one and the two marker
2668
+ // values when there is not.
2669
+ ...adopt ? { adopt } : {},
2670
+ // Then why choudoufu believes it. Rendered as the estate's own
2671
+ // words rather than a shape behold invented.
2672
+ ...matched.length ? { matchedOn: matched.map((m) => `${m.attribute}=${m.value}`).join(", ") } : {},
2673
+ ...base
2674
+ }
2675
+ };
2676
+ }
2652
2677
  if (omission.reason === "UNOWNED" && unowned) {
2653
2678
  const adopt = adoptLine(unowned);
2654
2679
  if (adopt) {
@@ -2918,6 +2943,12 @@ var init_member_kind = __esm({
2918
2943
  import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
2919
2944
  import { join as join4 } from "node:path";
2920
2945
  import { pathToFileURL } from "node:url";
2946
+ function readBehaviour(raw) {
2947
+ const b = raw.behaviour;
2948
+ if (!b || typeof b !== "object" || Array.isArray(b)) return void 0;
2949
+ const traffic = b.traffic;
2950
+ return typeof traffic === "string" && traffic.trim() ? { traffic: traffic.trim() } : void 0;
2951
+ }
2921
2952
  function readMember(v) {
2922
2953
  if (typeof v === "string") return v ? { dir: v, kind: "chant" } : void 0;
2923
2954
  if (!v || typeof v !== "object") return void 0;
@@ -3072,7 +3103,13 @@ function loadBeholdConfig(projectDir) {
3072
3103
  const tiers = readTiers(raw);
3073
3104
  const members = Array.isArray(raw.members) ? raw.members.map(readMember).filter((m) => !!m) : [];
3074
3105
  const executor = readExecutor(raw);
3075
- return { ...tiers ? { tiers } : {}, ...members.length ? { members } : {}, ...executor ? { executor } : {} };
3106
+ const behaviour = readBehaviour(raw);
3107
+ return {
3108
+ ...tiers ? { tiers } : {},
3109
+ ...members.length ? { members } : {},
3110
+ ...executor ? { executor } : {},
3111
+ ...behaviour ? { behaviour } : {}
3112
+ };
3076
3113
  } catch {
3077
3114
  return {};
3078
3115
  }
@@ -3142,8 +3179,12 @@ var init_project = __esm({
3142
3179
  import { createHash as createHash2 } from "node:crypto";
3143
3180
  import { readdirSync as readdirSync3, statSync } from "node:fs";
3144
3181
  import { join as join5, relative as relative2, resolve as resolve2 } from "node:path";
3182
+ function setStampExclusions(next) {
3183
+ exclusions = new Map([...next].map(([k, v]) => [resolve2(k), new Set(v.map((d) => resolve2(d)))]));
3184
+ }
3145
3185
  function memberSourceStamp(dir) {
3146
3186
  const root = resolve2(dir);
3187
+ const skipDirs = exclusions.get(root);
3147
3188
  const h = createHash2("sha1");
3148
3189
  let any = false;
3149
3190
  const walk = (at) => {
@@ -3152,7 +3193,7 @@ function memberSourceStamp(dir) {
3152
3193
  if (SKIP.has(e.name)) continue;
3153
3194
  const path = join5(at, e.name);
3154
3195
  if (e.isDirectory()) {
3155
- walk(path);
3196
+ if (!skipDirs?.has(path)) walk(path);
3156
3197
  continue;
3157
3198
  }
3158
3199
  if (!e.isFile()) continue;
@@ -3169,11 +3210,12 @@ function memberSourceStamp(dir) {
3169
3210
  }
3170
3211
  return any ? h.digest("hex") : void 0;
3171
3212
  }
3172
- var SKIP;
3213
+ var SKIP, exclusions;
3173
3214
  var init_member_source = __esm({
3174
3215
  "src/member-source.ts"() {
3175
3216
  "use strict";
3176
3217
  SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
3218
+ exclusions = /* @__PURE__ */ new Map();
3177
3219
  }
3178
3220
  });
3179
3221
 
@@ -3887,6 +3929,7 @@ var chant_exports = {};
3887
3929
  __export(chant_exports, {
3888
3930
  CI_FORGES: () => CI_FORGES,
3889
3931
  ChantCliError: () => ChantCliError,
3932
+ TRAFFIC_FLOOR: () => TRAFFIC_FLOOR,
3890
3933
  applyArgs: () => applyArgs,
3891
3934
  carveStatus: () => carveStatus,
3892
3935
  chantBinFrom: () => chantBinFrom,
@@ -3920,6 +3963,7 @@ __export(chant_exports, {
3920
3963
  lifecyclePlan: () => lifecyclePlan,
3921
3964
  lifecyclePlanArgs: () => lifecyclePlanArgs,
3922
3965
  meetsFloor: () => meetsFloor,
3966
+ memberTakesTraffic: () => memberTakesTraffic,
3923
3967
  parseCiPipeline: () => parseCiPipeline,
3924
3968
  pipelineEnvFromYaml: () => pipelineEnvFromYaml,
3925
3969
  readsInFlight: () => readsInFlight,
@@ -3940,7 +3984,7 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
3940
3984
  import { dirname as dirname4, join as join7, resolve as resolve5 } from "node:path";
3941
3985
  import { createHash as createHash3 } from "node:crypto";
3942
3986
  function envOverridesFor(opts) {
3943
- const overrides = {};
3987
+ const overrides = { ...opts.spawnEnv ?? {} };
3944
3988
  if (opts.tier && opts.tierEnvVar) overrides[opts.tierEnvVar] = opts.tier;
3945
3989
  if (opts.substrateTargets?.length) {
3946
3990
  Object.assign(overrides, targetEnvOverrides(opts.substrateTargets, opts.target));
@@ -3949,6 +3993,11 @@ function envOverridesFor(opts) {
3949
3993
  }
3950
3994
  return Object.keys(overrides).length ? overrides : void 0;
3951
3995
  }
3996
+ function memberTakesTraffic(dir, kind) {
3997
+ if (kind === "terraform") return true;
3998
+ if (kind !== "chant") return false;
3999
+ return meetsFloor(resolveChant(dir).version, TRAFFIC_FLOOR);
4000
+ }
3952
4001
  function graphFlags(opts) {
3953
4002
  const flags = [];
3954
4003
  if (opts.detail !== void 0) flags.push("--detail", String(opts.detail));
@@ -3959,6 +4008,7 @@ function graphFlags(opts) {
3959
4008
  if (opts.live) flags.push("--live");
3960
4009
  if (opts.overlay) flags.push("--overlay");
3961
4010
  if (opts.namespace) flags.push("--namespace", opts.namespace);
4011
+ if (opts.traffic) flags.push("--traffic", opts.traffic);
3962
4012
  return flags;
3963
4013
  }
3964
4014
  function packageManifestFrom(req, name) {
@@ -4304,7 +4354,7 @@ async function helmRenderDiffLive(projectDir, contentDigest, env, opts = {}) {
4304
4354
  function applyArgs(target, env) {
4305
4355
  return ["run", target, "--components", "--env", env, "--progress-json"];
4306
4356
  }
4307
- var reads, unstampableRead, ChantCliError, CI_FORGES;
4357
+ var TRAFFIC_FLOOR, reads, unstampableRead, ChantCliError, CI_FORGES;
4308
4358
  var init_chant = __esm({
4309
4359
  "src/chant.ts"() {
4310
4360
  "use strict";
@@ -4318,6 +4368,7 @@ var init_chant = __esm({
4318
4368
  init_yaml();
4319
4369
  init_carve_manifest();
4320
4370
  init_foreign();
4371
+ TRAFFIC_FLOOR = "0.75.0";
4321
4372
  reads = new ReadScheduler();
4322
4373
  unstampableRead = 0;
4323
4374
  ChantCliError = class extends Error {
@@ -4333,8 +4384,8 @@ var init_chant = __esm({
4333
4384
  });
4334
4385
 
4335
4386
  // src/cli.ts
4336
- import { resolve as resolve14, dirname as dirname9, join as join27 } from "node:path";
4337
- import { realpathSync as realpathSync2, existsSync as existsSync19, readFileSync as readFileSync21, rmSync as rmSync4 } from "node:fs";
4387
+ import { resolve as resolve18, dirname as dirname10, join as join29 } from "node:path";
4388
+ import { realpathSync as realpathSync2, existsSync as existsSync21, readFileSync as readFileSync22, rmSync as rmSync4 } from "node:fs";
4338
4389
  import { fileURLToPath as fileURLToPath6 } from "node:url";
4339
4390
  import { spawn as spawn7, spawnSync as spawnSync3 } from "node:child_process";
4340
4391
 
@@ -4392,8 +4443,8 @@ import { serve } from "@hono/node-server";
4392
4443
  import { fileURLToPath as fileURLToPath4 } from "node:url";
4393
4444
  import { execFile as execFile3 } from "node:child_process";
4394
4445
  import { promisify as promisify3 } from "node:util";
4395
- import { existsSync as existsSync17, readFileSync as readFileSync19 } from "node:fs";
4396
- import { basename as basename2, dirname as dirname7, join as join25, relative as relative8, resolve as resolve13, sep as sep7 } from "node:path";
4446
+ import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
4447
+ import { basename as basename3, dirname as dirname7, join as join26, relative as relative8, resolve as resolve16, sep as sep7 } from "node:path";
4397
4448
 
4398
4449
  // src/recents.ts
4399
4450
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -4635,19 +4686,19 @@ function finishPipelineProgress(state, exitCode) {
4635
4686
  }
4636
4687
 
4637
4688
  // src/gh-run.ts
4638
- var defaultGhExec = (args) => new Promise((resolve15) => {
4689
+ var defaultGhExec = (args) => new Promise((resolve19) => {
4639
4690
  let out = "";
4640
4691
  let proc;
4641
4692
  try {
4642
4693
  proc = spawn3("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
4643
4694
  } catch {
4644
- resolve15({ code: 127, out: "" });
4695
+ resolve19({ code: 127, out: "" });
4645
4696
  return;
4646
4697
  }
4647
4698
  proc.stdout.on("data", (d) => out += d);
4648
4699
  proc.stderr.on("data", (d) => out += d);
4649
- proc.on("error", () => resolve15({ code: 127, out }));
4650
- proc.on("close", (code) => resolve15({ code: code ?? 1, out }));
4700
+ proc.on("error", () => resolve19({ code: 127, out }));
4701
+ proc.on("close", (code) => resolve19({ code: code ?? 1, out }));
4651
4702
  });
4652
4703
  async function ghReady(exec = defaultGhExec) {
4653
4704
  const { code, out } = await exec(["auth", "status"]);
@@ -6143,7 +6194,7 @@ async function carveStatesUnder(dirs) {
6143
6194
  init_read_scheduler();
6144
6195
  init_chant();
6145
6196
  import { statSync as statSync5 } from "node:fs";
6146
- import { join as join12, resolve as resolve9, sep as sep4 } from "node:path";
6197
+ import { join as join13, resolve as resolve10, sep as sep4 } from "node:path";
6147
6198
  import { composeStacks, shortStackNames } from "@intentius/pinhole";
6148
6199
 
6149
6200
  // src/overlay-ir.ts
@@ -6271,6 +6322,241 @@ function tierMismatchNote(ir, tiers, currentTier) {
6271
6322
  return void 0;
6272
6323
  }
6273
6324
 
6325
+ // src/workspace.ts
6326
+ init_chant();
6327
+ init_floor();
6328
+ init_member_source();
6329
+ import { existsSync as existsSync8 } from "node:fs";
6330
+ import { join as join12, resolve as resolve9 } from "node:path";
6331
+ var WORKSPACE_CONTRACT = 1;
6332
+ var WORKSPACE_CHANT_FLOOR = "0.81.0";
6333
+ var DECLARATION_FILES = ["chant.workspace.json", "chant.workspace.jsonc"];
6334
+ function findDeclaration(dir) {
6335
+ const found = DECLARATION_FILES.filter((f) => existsSync8(join12(dir, f)));
6336
+ if (found.length > 1) return { ambiguous: true };
6337
+ return found.length === 1 ? { file: found[0] } : void 0;
6338
+ }
6339
+ function hasDeclaration(dir) {
6340
+ return findDeclaration(dir) !== void 0;
6341
+ }
6342
+ var refuse4 = (error, code, remedy) => ({ ok: false, refusal: { error, code, remedy } });
6343
+ var isRecord6 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6344
+ function parseWorkspaceLs(text, root) {
6345
+ let doc;
6346
+ try {
6347
+ doc = JSON.parse(text);
6348
+ } catch {
6349
+ return refuse4(`chant workspace ls printed something that is not JSON for ${root}.`, "workspace-ls", "Run `chant workspace ls --json` in the workspace root to see what it says.");
6350
+ }
6351
+ if (!isRecord6(doc)) return refuse4("chant workspace ls printed JSON that is not an object.", "workspace-ls", "Run `chant workspace ls --json` in the workspace root.");
6352
+ const contract = contractRefusal(doc, "ls");
6353
+ if (contract) return { ok: false, refusal: contract };
6354
+ if (isRecord6(doc.error)) {
6355
+ const code = String(doc.error.code ?? "unknown");
6356
+ return refuse4(`chant could not read the workspace declaration in ${root}: ${code}: ${String(doc.error.message ?? "")}`, code, "`chant workspace check` names the problem and where it is.");
6357
+ }
6358
+ const ws = doc.workspace;
6359
+ if (!isRecord6(ws) || typeof ws.name !== "string" || !Array.isArray(doc.members)) {
6360
+ return refuse4("That is not a workspace ls document: it has no `workspace` and `members`.", "workspace-ls", "Run `chant workspace ls --json` in the workspace root.");
6361
+ }
6362
+ const members = [];
6363
+ for (const m of doc.members) {
6364
+ if (!isRecord6(m) || typeof m.name !== "string" || typeof m.dir !== "string" || typeof m.kind !== "string") {
6365
+ return refuse4("A member in the workspace ls document has no name, dir or kind.", "workspace-ls", "Run `chant workspace ls --json` in the workspace root.");
6366
+ }
6367
+ const reason = isRecord6(m.reason) ? { code: String(m.reason.code), message: String(m.reason.message ?? "") } : null;
6368
+ members.push({
6369
+ name: m.name,
6370
+ dir: m.dir,
6371
+ kind: m.kind,
6372
+ abs: m.dir === "." ? resolve9(root) : resolve9(root, m.dir),
6373
+ reason,
6374
+ because: typeof m.because === "string" ? m.because : null
6375
+ });
6376
+ }
6377
+ return {
6378
+ ok: true,
6379
+ workspace: { name: ws.name, root: resolve9(root), file: typeof ws.file === "string" ? ws.file : "chant.workspace.json", chant: String(doc.chant ?? ""), members }
6380
+ };
6381
+ }
6382
+ function contractRefusal(doc, command) {
6383
+ if (doc.contract === WORKSPACE_CONTRACT) return void 0;
6384
+ const said4 = doc.contract === void 0 ? "no contract version" : `contract ${JSON.stringify(doc.contract)}`;
6385
+ return {
6386
+ error: `chant workspace ${command} wrote a document with ${said4}; behold reads contract ${WORKSPACE_CONTRACT}.`,
6387
+ code: "workspace-contract",
6388
+ remedy: doc.contract === void 0 ? `Upgrade the workspace's chant to ${WORKSPACE_CHANT_FLOOR} or newer.` : "Upgrade behold: this chant writes a newer workspace contract than it knows."
6389
+ };
6390
+ }
6391
+ async function readWorkspace(root) {
6392
+ const dir = resolve9(root);
6393
+ const found = findDeclaration(dir);
6394
+ if (!found) return refuse4(`${dir} has no chant.workspace.json.`, "declaration-missing", "`chant workspace init` proposes one.");
6395
+ if ("ambiguous" in found) {
6396
+ return refuse4(`${dir} has both chant.workspace.json and chant.workspace.jsonc.`, "declaration-ambiguous", "Keep one of the two files.");
6397
+ }
6398
+ const chant = resolveChant(dir);
6399
+ if (chant.version && !meetsFloor(chant.version, WORKSPACE_CHANT_FLOOR)) {
6400
+ return refuse4(
6401
+ `${dir} declares a chant workspace, and the chant behold would read it with is ${chant.version}; workspaces need ${WORKSPACE_CHANT_FLOOR} or newer.`,
6402
+ "workspace-chant-too-old",
6403
+ `Install @intentius/chant ${WORKSPACE_CHANT_FLOOR} or newer in the workspace root.`
6404
+ );
6405
+ }
6406
+ const run3 = await runChantRaw(["workspace", "ls", dir, "--json"], dir);
6407
+ if (!run3.stdout.trim()) {
6408
+ return refuse4(`chant workspace ls failed in ${dir} (exit ${run3.code}): ${run3.stderr.trim().split("\n").slice(-3).join(" ")}`, "workspace-ls", "Run `chant workspace ls` in the workspace root.");
6409
+ }
6410
+ return parseWorkspaceLs(run3.stdout, dir);
6411
+ }
6412
+ var served;
6413
+ var byDir = /* @__PURE__ */ new Map();
6414
+ function setServedWorkspace(ws) {
6415
+ served = ws;
6416
+ byDir.clear();
6417
+ for (const m of ws?.members ?? []) byDir.set(m.abs, m);
6418
+ const root = ws?.members.find((m) => m.dir === ".");
6419
+ setStampExclusions(root && ws ? /* @__PURE__ */ new Map([[root.abs, ws.members.filter((m) => m !== root).map((m) => m.abs)]]) : /* @__PURE__ */ new Map());
6420
+ }
6421
+ function servedWorkspace() {
6422
+ return served;
6423
+ }
6424
+ function workspaceMemberOf(dir) {
6425
+ return byDir.get(resolve9(dir));
6426
+ }
6427
+ var UNDRAWN_KINDS = /* @__PURE__ */ new Set(["other", "workspace"]);
6428
+ function drawnMembers(ws) {
6429
+ return ws.members.filter((m) => m.reason === null && !UNDRAWN_KINDS.has(m.kind));
6430
+ }
6431
+ function unreadableMembers(ws) {
6432
+ return ws.members.filter((m) => m.reason !== null && !UNDRAWN_KINDS.has(m.kind));
6433
+ }
6434
+ function undrawnMembers(ws) {
6435
+ return ws.members.filter((m) => UNDRAWN_KINDS.has(m.kind));
6436
+ }
6437
+ function unreadableMemberIr(m) {
6438
+ return {
6439
+ nodes: [
6440
+ {
6441
+ id: "unreadable",
6442
+ kind: "UnreadableMember",
6443
+ lexicon: "workspace",
6444
+ attrs: {
6445
+ _status: "neutral",
6446
+ _unreadable: { code: m.reason?.code ?? "unknown", message: m.reason?.message ?? "" },
6447
+ dir: m.dir,
6448
+ kind: m.kind
6449
+ }
6450
+ }
6451
+ ],
6452
+ edges: [],
6453
+ groups: {}
6454
+ };
6455
+ }
6456
+ function workspaceGraphTakes(opts) {
6457
+ return opts.detail === void 0 && !opts.lens && !opts.up && !opts.down && !opts.namespace;
6458
+ }
6459
+ var WorkspaceMemberError = class extends Error {
6460
+ constructor(member, reason) {
6461
+ super(`chant workspace graph could not read ${member}: ${reason.code}: ${reason.message}`);
6462
+ this.member = member;
6463
+ this.reason = reason;
6464
+ }
6465
+ member;
6466
+ reason;
6467
+ };
6468
+ function stripRefs(value, prefix) {
6469
+ if (Array.isArray(value)) return value.map((v) => stripRefs(v, prefix));
6470
+ if (!isRecord6(value)) return value;
6471
+ const out = {};
6472
+ for (const [k, v] of Object.entries(value)) {
6473
+ out[k] = k === "$ref" && typeof v === "string" && v.startsWith(prefix) ? v.slice(prefix.length) : stripRefs(v, prefix);
6474
+ }
6475
+ return out;
6476
+ }
6477
+ function memberIrFromWorkspaceGraph(text, member) {
6478
+ let doc;
6479
+ try {
6480
+ doc = JSON.parse(text);
6481
+ } catch {
6482
+ throw new Error("chant workspace graph printed something that is not JSON");
6483
+ }
6484
+ if (!isRecord6(doc)) throw new Error("chant workspace graph printed JSON that is not an object");
6485
+ const contract = contractRefusal(doc, "graph");
6486
+ if (contract) throw new Error(`${contract.error} ${contract.remedy}`);
6487
+ if (isRecord6(doc.error)) throw new Error(`chant workspace graph: ${String(doc.error.code)}: ${String(doc.error.message ?? "")}`);
6488
+ const entry = Array.isArray(doc.members) ? doc.members.find((m) => isRecord6(m) && m.name === member) : void 0;
6489
+ if (!isRecord6(entry)) throw new Error(`chant workspace graph did not list member ${member}`);
6490
+ if (entry.status !== "composed") {
6491
+ const r = isRecord6(entry.reason) ? entry.reason : {};
6492
+ throw new WorkspaceMemberError(member, { code: String(r.code ?? entry.status), message: String(r.message ?? "") });
6493
+ }
6494
+ const p = `${member}/`;
6495
+ const un = (id) => typeof id === "string" && id.startsWith(p) ? id.slice(p.length) : String(id);
6496
+ const mine = (x) => isRecord6(x) && x.member === member;
6497
+ const nodes = (Array.isArray(doc.nodes) ? doc.nodes : []).filter(mine).map((n) => {
6498
+ const { member: _m, ...rest } = n;
6499
+ const out = { ...rest, id: un(n.id), attrs: stripRefs(n.attrs ?? {}, p) };
6500
+ if (typeof n.compositeInstance === "string") out.compositeInstance = un(n.compositeInstance);
6501
+ if (typeof n.runtimeOwner === "string") out.runtimeOwner = un(n.runtimeOwner);
6502
+ return out;
6503
+ });
6504
+ const edges = (Array.isArray(doc.edges) ? doc.edges : []).filter(mine).map((e) => {
6505
+ const { member: _m, ...rest } = e;
6506
+ return { ...rest, from: un(e.from), to: un(e.to) };
6507
+ });
6508
+ const own = new Set(nodes.map((n) => n.id));
6509
+ const groups = {};
6510
+ if (isRecord6(doc.groups)) {
6511
+ for (const [key, table] of Object.entries(doc.groups)) {
6512
+ if (key === "byMember" || !isRecord6(table)) continue;
6513
+ const scoped = key === "byStack" || key === "byContainer" || key === "byWave";
6514
+ for (const [k, ids] of Object.entries(table)) {
6515
+ if (scoped && !k.startsWith(p)) continue;
6516
+ const members = (Array.isArray(ids) ? ids : []).map(un).filter((id) => own.has(id));
6517
+ if (members.length) (groups[key] ??= {})[scoped ? k.slice(p.length) : k] = members;
6518
+ }
6519
+ }
6520
+ }
6521
+ const exports = (Array.isArray(doc.exports) ? doc.exports : []).filter(mine).map((e) => {
6522
+ const { member: _m, ...rest } = e;
6523
+ return typeof e.node === "string" ? { ...rest, node: un(e.node) } : rest;
6524
+ });
6525
+ const imports = (Array.isArray(doc.imports) ? doc.imports : []).filter(mine).map((i) => {
6526
+ const { member: _m, ...rest } = i;
6527
+ return { ...rest, node: un(i.node) };
6528
+ });
6529
+ const ir = { version: 1, nodes, edges, groups, exports, imports };
6530
+ if (isRecord6(entry.meta)) ir.meta = entry.meta;
6531
+ if (entry.pipeline !== void 0) ir.pipeline = entry.pipeline;
6532
+ if (isRecord6(doc.derivedAttrs)) ir.derivedAttrs = doc.derivedAttrs;
6533
+ return ir;
6534
+ }
6535
+ function workspaceGraphArgs(root, member, opts) {
6536
+ const args = ["workspace", "graph", root, "--member", member];
6537
+ if (opts.env) args.push("--env", opts.env);
6538
+ if (opts.live) args.push("--live");
6539
+ if (opts.overlay) args.push("--overlay");
6540
+ if (opts.traffic) args.push("--traffic", opts.traffic);
6541
+ return args;
6542
+ }
6543
+ var CONTRACT_READ_KINDS = /* @__PURE__ */ new Set(["chant", "terraform"]);
6544
+ function workspaceVia(member, root, fallback) {
6545
+ return {
6546
+ tool: (dir) => {
6547
+ const chant = resolveChant(root);
6548
+ return `workspace\0${chant.bin}\0${chant.version ?? ""}\0${fallback.tool(dir)}`;
6549
+ },
6550
+ read: async (dir, opts) => {
6551
+ if (!workspaceGraphTakes(opts)) return fallback.read(dir, opts);
6552
+ const args = workspaceGraphArgs(root, member.name, opts);
6553
+ const run3 = await runChantRaw(args, root, envOverridesFor(opts));
6554
+ if (!run3.stdout.trim()) throw new Error(`chant ${args.join(" ")} exited ${run3.code}: ${run3.stderr.trim()}`);
6555
+ return memberIrFromWorkspaceGraph(run3.stdout, member.name);
6556
+ }
6557
+ };
6558
+ }
6559
+
6274
6560
  // src/estate.ts
6275
6561
  function estateReadPool(members, env = process.env) {
6276
6562
  return Math.max(1, Math.min(readConcurrency(env), members));
@@ -6287,14 +6573,35 @@ async function mapPool(items, width, fn) {
6287
6573
  return results;
6288
6574
  }
6289
6575
  function estateMembers(projectDirs) {
6290
- const names = shortStackNames([...projectDirs]);
6291
- return projectDirs.map((dir, i) => ({ name: names[i], dir, kind: memberKindOf(dir) ?? "chant" }));
6576
+ const names = estateMemberNames(projectDirs);
6577
+ const kindOf2 = (dir) => workspaceMemberOf(dir)?.kind ?? memberKindOf(dir) ?? "chant";
6578
+ return projectDirs.map((dir, i) => ({ name: names[i], dir, kind: kindOf2(dir) }));
6579
+ }
6580
+ function estateMemberNames(projectDirs) {
6581
+ const declared = projectDirs.map((d) => workspaceMemberOf(d)?.name);
6582
+ if (declared.every((n) => n !== void 0)) return declared;
6583
+ return shortStackNames([...projectDirs]);
6584
+ }
6585
+ function unreadableStacks(projectDirs) {
6586
+ const ws = servedWorkspace();
6587
+ if (!ws || !projectDirs.every((d) => workspaceMemberOf(d))) return [];
6588
+ return unreadableMembers(ws).map((m) => ({ name: m.name, ir: unreadableMemberIr(m) }));
6589
+ }
6590
+ var memberViaFor = (dir) => {
6591
+ const own = memberKindSpec(memberKindOf(dir) ?? "chant")?.via ?? chantVia;
6592
+ const declared = workspaceMemberOf(dir);
6593
+ const ws = servedWorkspace();
6594
+ return declared && ws && CONTRACT_READ_KINDS.has(declared.kind) ? workspaceVia(declared, ws.root, own) : own;
6595
+ };
6596
+ function forMember(dir, opts) {
6597
+ if (!opts.traffic || memberTakesTraffic(dir, memberKindOf(dir) ?? "chant")) return opts;
6598
+ const { traffic: _traffic, ...rest } = opts;
6599
+ return rest;
6292
6600
  }
6293
- var memberViaFor = (dir) => memberKindSpec(memberKindOf(dir) ?? "chant")?.via ?? chantVia;
6294
- var memberSource = (dir, opts) => memberIr(dir, opts, memberViaFor(dir));
6295
- var memberLive = (dir, opts, fresh) => overlayIr(dir, opts, memberViaFor(dir), fresh);
6601
+ var memberSource = (dir, opts) => memberIr(dir, forMember(dir, opts), memberViaFor(dir));
6602
+ var memberLive = (dir, opts, fresh) => overlayIr(dir, forMember(dir, opts), memberViaFor(dir), fresh);
6296
6603
  async function composeEstate(projectDirs, opts = {}) {
6297
- const names = shortStackNames(projectDirs);
6604
+ const names = estateMemberNames(projectDirs);
6298
6605
  const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
6299
6606
  name: names[i],
6300
6607
  // chant#2040: the manifests a member holds name the files its carved
@@ -6302,17 +6609,17 @@ async function composeEstate(projectDirs, opts = {}) {
6302
6609
  // known, onto a COPY (the member IR is the #312 cache's own object).
6303
6610
  ir: joinCarvedSources(await memberSource(dir, opts), (await carveStatesFor(dir)).values(), dir).ir
6304
6611
  }));
6305
- return composeStacks(stacks);
6612
+ return composeStacks([...stacks, ...unreadableStacks(projectDirs)]);
6306
6613
  }
6307
6614
  async function composeEstatePending(projectDirs, opts = {}) {
6308
- const names = shortStackNames(projectDirs);
6615
+ const names = estateMemberNames(projectDirs);
6309
6616
  const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
6310
6617
  const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
6311
6618
  const ir = joinCarvedSources(await memberSource(dir, srcOpts), (await carveStatesFor(dir)).values(), dir).ir;
6312
6619
  for (const n of ir.nodes) n.attrs = { ...n.attrs, _pendingRead: true };
6313
6620
  return { name: names[i], ir };
6314
6621
  });
6315
- return composeStacks(stacks);
6622
+ return composeStacks([...stacks, ...unreadableStacks(projectDirs)]);
6316
6623
  }
6317
6624
  var firstLine2 = (e) => {
6318
6625
  const msg = e instanceof Error ? e.message : String(e);
@@ -6361,11 +6668,11 @@ function declaredSegments(path) {
6361
6668
  function pathAlignment(declared, dir, isDir = realIsDir) {
6362
6669
  const want = declaredSegments(declared);
6363
6670
  if (!want) return 0;
6364
- const root = resolve9(dir);
6671
+ const root = resolve10(dir);
6365
6672
  const have = root.split(sep4).filter(Boolean);
6366
6673
  for (let k = Math.min(want.length, have.length); k >= 1; k--) {
6367
6674
  if (!have.slice(-k).every((s, i) => s === want[i])) continue;
6368
- if (!isDir(join12(root, ...want.slice(k)))) continue;
6675
+ if (!isDir(join13(root, ...want.slice(k)))) continue;
6369
6676
  return k;
6370
6677
  }
6371
6678
  return 0;
@@ -6395,10 +6702,10 @@ function joinNamespaceBindings(members, isDir = realIsDir) {
6395
6702
  if (!winner || tied) continue;
6396
6703
  const prev = best.get(winner.dir);
6397
6704
  if (prev === "ambiguous") continue;
6398
- const join28 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
6399
- if (!prev) best.set(winner.dir, { score: winner.score, join: join28 });
6705
+ const join30 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
6706
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join30 });
6400
6707
  else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
6401
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join28 });
6708
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join30 });
6402
6709
  }
6403
6710
  return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
6404
6711
  }
@@ -6422,7 +6729,7 @@ function withoutJoinedMembers(nodes, joined) {
6422
6729
  return nodes.filter((n) => !joined.some((j) => n.id.startsWith(`${j.name}/`)));
6423
6730
  }
6424
6731
  async function composeEstateOverlay(projectDirs, opts, classify, observe = {}) {
6425
- const names = shortStackNames(projectDirs);
6732
+ const names = estateMemberNames(projectDirs);
6426
6733
  const unobserved = [];
6427
6734
  const dropped = [];
6428
6735
  const joined = [];
@@ -6456,7 +6763,7 @@ async function composeEstateOverlay(projectDirs, opts, classify, observe = {}) {
6456
6763
  });
6457
6764
  const present = stacks.filter((s) => !!s);
6458
6765
  return {
6459
- ir: composeStacks(present),
6766
+ ir: composeStacks([...present, ...unreadableStacks(projectDirs)]),
6460
6767
  observed: present.length - unobserved.length,
6461
6768
  total: projectDirs.length,
6462
6769
  unobserved,
@@ -7123,8 +7430,8 @@ function projectHelmLogical(ir) {
7123
7430
  }
7124
7431
 
7125
7432
  // src/logical-kustomize.ts
7126
- import { existsSync as existsSync8 } from "node:fs";
7127
- import { join as join13 } from "node:path";
7433
+ import { existsSync as existsSync9 } from "node:fs";
7434
+ import { join as join14 } from "node:path";
7128
7435
  function overlayBoxTitle(name) {
7129
7436
  return `overlay ${name}`;
7130
7437
  }
@@ -7144,7 +7451,7 @@ function kustomizationRoot(bases, dir, exists) {
7144
7451
  let current = dir;
7145
7452
  for (let i = 0; i < 4 && current; i++) {
7146
7453
  for (const base of bases) {
7147
- if (exists(join13(base, current, "kustomization.yaml")) || exists(join13(base, current, "kustomization.yml"))) {
7454
+ if (exists(join14(base, current, "kustomization.yaml")) || exists(join14(base, current, "kustomization.yml"))) {
7148
7455
  return current;
7149
7456
  }
7150
7457
  }
@@ -7153,7 +7460,7 @@ function kustomizationRoot(bases, dir, exists) {
7153
7460
  }
7154
7461
  return void 0;
7155
7462
  }
7156
- function projectKustomizeLogical(ir, sourceRoots, exists = existsSync8) {
7463
+ function projectKustomizeLogical(ir, sourceRoots, exists = existsSync9) {
7157
7464
  const bases = Array.isArray(sourceRoots) ? sourceRoots : [sourceRoots];
7158
7465
  const k8s = ir.nodes.filter((n) => n.lexicon === "k8s");
7159
7466
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
@@ -7934,9 +8241,9 @@ import {
7934
8241
 
7935
8242
  // src/icon-packs.ts
7936
8243
  import { readFileSync as readFileSync10 } from "node:fs";
7937
- import { dirname as dirname5, join as join14 } from "node:path";
8244
+ import { dirname as dirname5, join as join15 } from "node:path";
7938
8245
  import { fileURLToPath as fileURLToPath3 } from "node:url";
7939
- var ICON_ROOT = join14(dirname5(fileURLToPath3(import.meta.url)), "..", "web", "icons");
8246
+ var ICON_ROOT = join15(dirname5(fileURLToPath3(import.meta.url)), "..", "web", "icons");
7940
8247
  var cache4 = /* @__PURE__ */ new Map();
7941
8248
  function unprefix(body) {
7942
8249
  return body.replace(/\s(?:inkscape|sodipodi):[\w-]+\s*=\s*(["'])[\s\S]*?\1/g, "").replace(/\sxlink:href\s*=/g, " href=");
@@ -7958,7 +8265,7 @@ function plate(glyph) {
7958
8265
  function loadIcon(rel) {
7959
8266
  const hit = cache4.get(rel);
7960
8267
  if (hit) return hit;
7961
- const raw = readFileSync10(join14(ICON_ROOT, `${rel}.svg`), "utf8");
8268
+ const raw = readFileSync10(join15(ICON_ROOT, `${rel}.svg`), "utf8");
7962
8269
  const open = raw.indexOf("<svg");
7963
8270
  const openEnd = raw.indexOf(">", open);
7964
8271
  const close = raw.lastIndexOf("</svg");
@@ -8119,22 +8426,22 @@ init_carve_manifest();
8119
8426
 
8120
8427
  // src/ops-lens.ts
8121
8428
  var SUPPORTED_MAJOR2 = 1;
8122
- var refuse4 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
8429
+ var refuse5 = (error, remedy) => ({ ok: false, refusal: { error, code: "op-ir", remedy } });
8123
8430
  var HOW_TO_EMIT = "Build the project with a chant >= 0.50 (`chant build --lexicon temporal`), which writes `dist/ops/<name>/op.json` beside each Op's generated workflow.";
8124
- var isRecord6 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8431
+ var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8125
8432
  function parseOpIr(value) {
8126
- if (!isRecord6(value)) {
8127
- return refuse4("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
8433
+ if (!isRecord7(value)) {
8434
+ return refuse5("That file isn't an op.json \u2014 its top level isn't a JSON object.", HOW_TO_EMIT);
8128
8435
  }
8129
8436
  if (typeof value.name !== "string" || !Array.isArray(value.phases)) {
8130
- return refuse4(
8437
+ return refuse5(
8131
8438
  "That JSON doesn't look like an op.json \u2014 it needs a string `name` and a `phases` array.",
8132
8439
  HOW_TO_EMIT
8133
8440
  );
8134
8441
  }
8135
- const bad2 = value.phases.findIndex((p) => !isRecord6(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
8442
+ const bad2 = value.phases.findIndex((p) => !isRecord7(p) || typeof p.name !== "string" || !Array.isArray(p.steps));
8136
8443
  if (bad2 >= 0) {
8137
- return refuse4(
8444
+ return refuse5(
8138
8445
  `That JSON has a \`phases\` array, but entry ${bad2} isn't a phase (needs a string \`name\` and a \`steps\` array).`,
8139
8446
  HOW_TO_EMIT
8140
8447
  );
@@ -8143,7 +8450,7 @@ function parseOpIr(value) {
8143
8450
  if (version !== void 0) {
8144
8451
  const major = Number(String(version).split(".")[0]);
8145
8452
  if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR2) {
8146
- return refuse4(
8453
+ return refuse5(
8147
8454
  `This op.json declares format version ${String(version)}; behold reads version ${SUPPORTED_MAJOR2}.`,
8148
8455
  "Upgrade behold, or render the Op with a behold that speaks its version."
8149
8456
  );
@@ -8156,13 +8463,13 @@ function readOpIr(path, readFile) {
8156
8463
  try {
8157
8464
  text = readFile(path);
8158
8465
  } catch (err) {
8159
- return refuse4(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
8466
+ return refuse5(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
8160
8467
  }
8161
8468
  let json;
8162
8469
  try {
8163
8470
  json = JSON.parse(text);
8164
8471
  } catch (err) {
8165
- return refuse4(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
8472
+ return refuse5(`${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_EMIT);
8166
8473
  }
8167
8474
  return parseOpIr(json);
8168
8475
  }
@@ -8229,14 +8536,14 @@ function opPlacedSteps(op) {
8229
8536
  }
8230
8537
  var RULE_BOX = (opName) => `${opName} \xB7 rule table`;
8231
8538
  var ruleId = (opName, rule) => `${opName}/rules/${rule.id}`;
8232
- var isPredicate = (v) => isRecord6(v) && typeof v.kind === "string";
8539
+ var isPredicate = (v) => isRecord7(v) && typeof v.kind === "string";
8233
8540
  function isConvergeRule(v) {
8234
- if (!isRecord6(v)) return false;
8541
+ if (!isRecord7(v)) return false;
8235
8542
  if (typeof v.id !== "string" || !v.id) return false;
8236
8543
  if (typeof v.why !== "string" || !v.why) return false;
8237
8544
  if (!isPredicate(v.when)) return false;
8238
8545
  const then = v.then;
8239
- if (!isRecord6(then)) return false;
8546
+ if (!isRecord7(then)) return false;
8240
8547
  return then.kind === "run" && typeof then.op === "string" || then.kind === "report" && typeof then.reason === "string";
8241
8548
  }
8242
8549
  function convergeTable(op) {
@@ -8577,15 +8884,15 @@ function entityCountClause(ir) {
8577
8884
  var LAYOUT_ROW_VIA = "_layout-row";
8578
8885
  var ROW_ASPECT = 2.4;
8579
8886
  var ROW_MIN_CARDS = 6;
8580
- function rowColumns(n, cell, sep8, aspect = ROW_ASPECT) {
8887
+ function rowColumns(n, cell, sep9, aspect = ROW_ASPECT) {
8581
8888
  if (n < 2) return Math.max(1, n);
8582
- const cols = Math.round(Math.sqrt(n * (cell.h + sep8.y) * aspect / (cell.w + sep8.x)));
8889
+ const cols = Math.round(Math.sqrt(n * (cell.h + sep9.y) * aspect / (cell.w + sep9.x)));
8583
8890
  return Math.max(1, Math.min(n, cols));
8584
8891
  }
8585
8892
  var FALLBACK = { w: 175, h: 104 };
8586
8893
  function withRowChains(ir, groups, opts) {
8587
8894
  const min = opts.min ?? ROW_MIN_CARDS;
8588
- const sep8 = { x: opts.nodesep ?? 64, y: opts.ranksep ?? 72 };
8895
+ const sep9 = { x: opts.nodesep ?? 64, y: opts.ranksep ?? 72 };
8589
8896
  const known = new Set(ir.nodes.map((n) => n.id));
8590
8897
  const boxOf2 = /* @__PURE__ */ new Map();
8591
8898
  for (const [key, ids] of Object.entries(groups)) for (const id of ids) if (known.has(id)) boxOf2.set(id, key);
@@ -8606,7 +8913,7 @@ function withRowChains(ir, groups, opts) {
8606
8913
  },
8607
8914
  { w: 0, h: 0 }
8608
8915
  );
8609
- const cols = rowColumns(ids.length, cell.w ? cell : FALLBACK, sep8, opts.aspect);
8916
+ const cols = rowColumns(ids.length, cell.w ? cell : FALLBACK, sep9, opts.aspect);
8610
8917
  if (cols >= ids.length) continue;
8611
8918
  const bands = bandsOf(ids, opts.bandOf);
8612
8919
  let rows = 0;
@@ -9364,17 +9671,17 @@ function gridComponent(one, sizes) {
9364
9671
  },
9365
9672
  { w: NODE_W, h: NODE_H }
9366
9673
  );
9367
- const sep8 = { x: 48, y: 60 };
9368
- const cols = rowColumns(order.length, cell, sep8);
9674
+ const sep9 = { x: 48, y: 60 };
9675
+ const cols = rowColumns(order.length, cell, sep9);
9369
9676
  const local = /* @__PURE__ */ new Map();
9370
9677
  order.forEach((n, i) => {
9371
9678
  local.set(n.id, {
9372
- x: i % cols * (cell.w + sep8.x) + cell.w / 2,
9373
- y: Math.floor(i / cols) * (cell.h + sep8.y) + cell.h / 2
9679
+ x: i % cols * (cell.w + sep9.x) + cell.w / 2,
9680
+ y: Math.floor(i / cols) * (cell.h + sep9.y) + cell.h / 2
9374
9681
  });
9375
9682
  });
9376
9683
  const rows = Math.ceil(order.length / cols);
9377
- return { local, w: Math.min(cols, order.length) * (cell.w + sep8.x) - sep8.x, h: rows * (cell.h + sep8.y) - sep8.y };
9684
+ return { local, w: Math.min(cols, order.length) * (cell.w + sep9.x) - sep9.x, h: rows * (cell.h + sep9.y) - sep9.y };
9378
9685
  }
9379
9686
  function packBoxComponents(layout, ir, boxes, bandOf) {
9380
9687
  const grids = /* @__PURE__ */ new Map();
@@ -9618,12 +9925,12 @@ init_carve_manifest();
9618
9925
 
9619
9926
  // src/carve-actions.ts
9620
9927
  init_chant();
9621
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync13, readdirSync as readdirSync8, statSync as statSync6 } from "node:fs";
9622
- import { isAbsolute as isAbsolute3, join as join17, relative as relative4, resolve as resolve10, sep as sep5 } from "node:path";
9928
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync13, readdirSync as readdirSync8, statSync as statSync6 } from "node:fs";
9929
+ import { isAbsolute as isAbsolute3, join as join18, relative as relative4, resolve as resolve11, sep as sep5 } from "node:path";
9623
9930
 
9624
9931
  // src/layout.ts
9625
- import { existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync11, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync4, accessSync, constants } from "node:fs";
9626
- import { join as join15 } from "node:path";
9932
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync11, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync4, accessSync, constants } from "node:fs";
9933
+ import { join as join16 } from "node:path";
9627
9934
  var LAYOUT_DIR = ".behold";
9628
9935
  var LAYOUT_FILE = "layout.json";
9629
9936
  var MAX_BODY_BYTES = 64 * 1024;
@@ -9664,10 +9971,10 @@ function lensFromQuery(params) {
9664
9971
  return [slug2(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug2(`stack-${stack}`) : null].filter(Boolean).join("+");
9665
9972
  }
9666
9973
  function layoutPath(projectDir) {
9667
- return join15(projectDir, LAYOUT_DIR, LAYOUT_FILE);
9974
+ return join16(projectDir, LAYOUT_DIR, LAYOUT_FILE);
9668
9975
  }
9669
9976
  function unwritableReason(projectDir) {
9670
- if (!projectDir || !existsSync9(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
9977
+ if (!projectDir || !existsSync10(projectDir)) return `no such project directory: ${projectDir || "(none)"}`;
9671
9978
  try {
9672
9979
  accessSync(projectDir, constants.W_OK);
9673
9980
  } catch {
@@ -9679,7 +9986,7 @@ function readLayoutFile(projectDir) {
9679
9986
  const empty = { version: 1, lenses: {} };
9680
9987
  const file = layoutPath(projectDir);
9681
9988
  try {
9682
- if (!existsSync9(file)) return empty;
9989
+ if (!existsSync10(file)) return empty;
9683
9990
  const raw = readFileSync11(file, "utf8");
9684
9991
  if (raw.length > MAX_FILE_BYTES) return empty;
9685
9992
  const parsed = JSON.parse(raw);
@@ -9720,7 +10027,7 @@ function writeLens(projectDir, lens, deltas) {
9720
10027
  }
9721
10028
  const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n";
9722
10029
  if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`);
9723
- mkdirSync4(join15(projectDir, LAYOUT_DIR), { recursive: true });
10030
+ mkdirSync4(join16(projectDir, LAYOUT_DIR), { recursive: true });
9724
10031
  const tmp = `${file}.tmp`;
9725
10032
  writeFileSync4(tmp, body, "utf8");
9726
10033
  renameSync(tmp, file);
@@ -9806,8 +10113,8 @@ function reanchorEdges(svg, moved) {
9806
10113
  // src/carve-live.ts
9807
10114
  init_scratch();
9808
10115
  import { spawn as spawn4 } from "node:child_process";
9809
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync5, existsSync as existsSync10 } from "node:fs";
9810
- import { join as join16 } from "node:path";
10116
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync5, existsSync as existsSync11 } from "node:fs";
10117
+ import { join as join17 } from "node:path";
9811
10118
  var LIVE_CONTAINER = "behold-carve-floci";
9812
10119
  var LIVE_PORT = 4602;
9813
10120
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -9867,9 +10174,9 @@ async function teardownScratchFloci() {
9867
10174
  await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
9868
10175
  }
9869
10176
  async function applyIntoFloci(fromDir, step) {
9870
- const template = join16(fromDir, "floci-override.tf.disabled");
9871
- if (!existsSync10(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
9872
- writeFileSync5(join16(fromDir, "floci_override.tf"), armOverride(readFileSync12(template, "utf8"), LIVE_PORT));
10177
+ const template = join17(fromDir, "floci-override.tf.disabled");
10178
+ if (!existsSync11(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
10179
+ writeFileSync5(join17(fromDir, "floci_override.tf"), armOverride(readFileSync12(template, "utf8"), LIVE_PORT));
9873
10180
  const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
9874
10181
  if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
9875
10182
  const apply = await step(
@@ -9893,7 +10200,7 @@ ${tail}`, exitCode: r.code };
9893
10200
  // src/carve-actions.ts
9894
10201
  async function runCarvePlan(demo) {
9895
10202
  if (!demo?.live) {
9896
- return refuse5(
10203
+ return refuse6(
9897
10204
  "carve-action",
9898
10205
  "terraform plan is the live tier's beat, and this server isn't serving one",
9899
10206
  "run `behold demo carve --live` (needs docker + terraform)"
@@ -9901,7 +10208,7 @@ async function runCarvePlan(demo) {
9901
10208
  }
9902
10209
  const r = await runLivePlan(demo.from);
9903
10210
  if ("error" in r) {
9904
- return refuse5("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
10211
+ return refuse6("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
9905
10212
  }
9906
10213
  return {
9907
10214
  ok: true,
@@ -9913,14 +10220,14 @@ async function runCarvePlan(demo) {
9913
10220
  }
9914
10221
  async function runCarveObserve(demo, select) {
9915
10222
  if (!demo.live) {
9916
- return refuse5(
10223
+ return refuse6(
9917
10224
  "carve-action",
9918
10225
  "the observe beat reads the scratch Floci, and this server isn't serving one",
9919
10226
  "run `behold demo carve --live` (needs docker + terraform)"
9920
10227
  );
9921
10228
  }
9922
- if (!existsSync11(join17(demo.out, "src"))) {
9923
- return refuse5("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
10229
+ if (!existsSync12(join18(demo.out, "src"))) {
10230
+ return refuse6("carve-action", "nothing has been emitted yet", "run Emit first \u2014 observe reads the carveout emit writes");
9924
10231
  }
9925
10232
  const args = ["lifecycle", "diff", "prod", "--live", "--json"];
9926
10233
  const run3 = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err) => ({
@@ -9929,7 +10236,7 @@ async function runCarveObserve(demo, select) {
9929
10236
  stderr: err instanceof Error ? err.message : String(err)
9930
10237
  }));
9931
10238
  if (run3.code !== 0) {
9932
- return refuse5(
10239
+ return refuse6(
9933
10240
  "carve-action",
9934
10241
  `chant lifecycle diff exited ${run3.code}: ${merge(run3, demo.root) || "(no output)"}`,
9935
10242
  "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."
@@ -9939,10 +10246,10 @@ async function runCarveObserve(demo, select) {
9939
10246
  try {
9940
10247
  parsed = JSON.parse(run3.stdout);
9941
10248
  } catch {
9942
- return refuse5("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
10249
+ return refuse6("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run \u2014 a partial read is not a verdict");
9943
10250
  }
9944
10251
  if (parsed.stacks && !parsed.lexicons) {
9945
- return refuse5(
10252
+ return refuse6(
9946
10253
  "carve-action",
9947
10254
  "chant lifecycle diff answered a stacked shape ({stacks}) \u2014 observe only reads the stack-less {lexicons} shape",
9948
10255
  "point the target at a stack-less carveout, or file a shape-read gap against observe for the stacked case"
@@ -9983,13 +10290,13 @@ async function runCarveObserve(demo, select) {
9983
10290
  var MAX_ARTIFACT_BYTES = 64 * 1024;
9984
10291
  var MAX_ARTIFACTS = 24;
9985
10292
  var MAX_OUTPUT_BYTES = 32 * 1024;
9986
- var refuse5 = (code, error, remedy) => ({
10293
+ var refuse6 = (code, error, remedy) => ({
9987
10294
  ok: false,
9988
10295
  refusal: { error, code, remedy }
9989
10296
  });
9990
10297
  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.";
9991
10298
  function insideDemo(root, p) {
9992
- const rel = relative4(resolve10(root), resolve10(p));
10299
+ const rel = relative4(resolve11(root), resolve11(p));
9993
10300
  return rel !== "" && !rel.startsWith("..") && !isAbsolute3(rel);
9994
10301
  }
9995
10302
  function selectFromReport(report, raw) {
@@ -10000,7 +10307,7 @@ function carveWriteBlock(demo) {
10000
10307
  if (!demo) {
10001
10308
  return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
10002
10309
  }
10003
- if (!existsSync11(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
10310
+ if (!existsSync12(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
10004
10311
  if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
10005
10312
  return unwritableReason(demo.root);
10006
10313
  }
@@ -10017,7 +10324,7 @@ function readArtifacts(demo, dir, filter) {
10017
10324
  for (const name of entries) {
10018
10325
  if (out.length >= MAX_ARTIFACTS) return;
10019
10326
  if (name === "node_modules" || name.startsWith(".")) continue;
10020
- const full = join17(d, name);
10327
+ const full = join18(d, name);
10021
10328
  let st;
10022
10329
  try {
10023
10330
  st = statSync6(full);
@@ -10062,9 +10369,9 @@ function merge(r, root) {
10062
10369
  }
10063
10370
  async function runCarveEmit(demo, select) {
10064
10371
  const block = carveWriteBlock(demo);
10065
- if (block) return refuse5("read-only", block, "Start the walkthrough with `behold demo carve`.");
10372
+ if (block) return refuse6("read-only", block, "Start the walkthrough with `behold demo carve`.");
10066
10373
  mkdirSync5(demo.out, { recursive: true });
10067
- const reportFile = join17(demo.out, `${carveSlug(select)}-boundary.json`);
10374
+ const reportFile = join18(demo.out, `${carveSlug(select)}-boundary.json`);
10068
10375
  const args = [
10069
10376
  "carve",
10070
10377
  "emit",
@@ -10085,10 +10392,10 @@ async function runCarveEmit(demo, select) {
10085
10392
  }));
10086
10393
  const output = merge(run3, demo.root);
10087
10394
  if (run3.code !== 0) {
10088
- return refuse5(
10395
+ return refuse6(
10089
10396
  "carve-action",
10090
10397
  `chant carve emit exited ${run3.code}: ${output || "(no output)"}`,
10091
- "The offline emit needs `@cdktf/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
10398
+ "The offline emit needs `@cdktn/hcl2json` in the demo copy and the demo's own chant install \u2014 `npm install` in the copy's `app/` and re-run `behold demo carve`."
10092
10399
  );
10093
10400
  }
10094
10401
  let boundary = null;
@@ -10096,7 +10403,7 @@ async function runCarveEmit(demo, select) {
10096
10403
  boundary = JSON.parse(readFileSync13(reportFile, "utf8"));
10097
10404
  } catch {
10098
10405
  }
10099
- const lintPath = relative4(demo.project, join17(demo.out, "src")).split(sep5).join("/");
10406
+ const lintPath = relative4(demo.project, join18(demo.out, "src")).split(sep5).join("/");
10100
10407
  const lintArgs = ["lint", lintPath];
10101
10408
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
10102
10409
  code: 127,
@@ -10120,7 +10427,7 @@ async function runCarveEmit(demo, select) {
10120
10427
  }
10121
10428
  async function runCarveBridge(demo, select) {
10122
10429
  const block = carveWriteBlock(demo);
10123
- if (block) return refuse5("read-only", block, "Start the walkthrough with `behold demo carve`.");
10430
+ if (block) return refuse6("read-only", block, "Start the walkthrough with `behold demo carve`.");
10124
10431
  mkdirSync5(demo.out, { recursive: true });
10125
10432
  const args = [
10126
10433
  "carve",
@@ -10140,7 +10447,7 @@ async function runCarveBridge(demo, select) {
10140
10447
  }));
10141
10448
  const output = merge(run3, demo.root);
10142
10449
  if (run3.code !== 0) {
10143
- return refuse5(
10450
+ return refuse6(
10144
10451
  "carve-action",
10145
10452
  `chant carve bridge exited ${run3.code}: ${output || "(no output)"}`,
10146
10453
  "Run the Emit step first \u2014 bridge reads the carve manifest emit leaves in the output directory."
@@ -10159,8 +10466,8 @@ async function runCarveBridge(demo, select) {
10159
10466
  }
10160
10467
 
10161
10468
  // src/ops.ts
10162
- import { readdirSync as readdirSync9, readFileSync as readFileSync14, existsSync as existsSync12 } from "node:fs";
10163
- import { join as join18 } from "node:path";
10469
+ import { readdirSync as readdirSync9, readFileSync as readFileSync14, existsSync as existsSync13 } from "node:fs";
10470
+ import { join as join19 } from "node:path";
10164
10471
  var APPLY_TARGET_LEXICON = {
10165
10472
  cloudformation: "aws",
10166
10473
  kubectl: "k8s",
@@ -10182,11 +10489,11 @@ function discoverOps(projectDir) {
10182
10489
  const seen = /* @__PURE__ */ new Set();
10183
10490
  const out = [];
10184
10491
  for (const sub of ["ops", "src", "."]) {
10185
- const dir = join18(projectDir, sub);
10186
- if (!existsSync12(dir)) continue;
10492
+ const dir = join19(projectDir, sub);
10493
+ if (!existsSync13(dir)) continue;
10187
10494
  for (const f of readdirSync9(dir)) {
10188
10495
  if (!f.endsWith(".op.ts")) continue;
10189
- const content = readFileSync14(join18(dir, f), "utf8");
10496
+ const content = readFileSync14(join19(dir, f), "utf8");
10190
10497
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
10191
10498
  if (!name || seen.has(name)) continue;
10192
10499
  seen.add(name);
@@ -10202,25 +10509,25 @@ function discoverOps(projectDir) {
10202
10509
  ...gate ? { gate } : {},
10203
10510
  ...env ? { env } : {},
10204
10511
  ...substrate ? { substrate } : {},
10205
- ...existsSync12(irPath) ? { irPath } : {}
10512
+ ...existsSync13(irPath) ? { irPath } : {}
10206
10513
  });
10207
10514
  }
10208
10515
  }
10209
10516
  return out.sort((a, b) => a.name.localeCompare(b.name));
10210
10517
  }
10211
10518
  function opIrPath(projectDir, name) {
10212
- return join18(projectDir, "dist", "ops", name, "op.json");
10519
+ return join19(projectDir, "dist", "ops", name, "op.json");
10213
10520
  }
10214
10521
  function discoverOpIrs(projectDirs) {
10215
10522
  const seen = /* @__PURE__ */ new Set();
10216
10523
  const out = [];
10217
10524
  for (const dir of projectDirs) {
10218
- const root = join18(dir, "dist", "ops");
10219
- if (!existsSync12(root)) continue;
10525
+ const root = join19(dir, "dist", "ops");
10526
+ if (!existsSync13(root)) continue;
10220
10527
  for (const name of readdirSync9(root)) {
10221
10528
  if (seen.has(name)) continue;
10222
10529
  const path = opIrPath(dir, name);
10223
- if (!existsSync12(path)) continue;
10530
+ if (!existsSync13(path)) continue;
10224
10531
  seen.add(name);
10225
10532
  out.push({ name, dir, path });
10226
10533
  }
@@ -10244,9 +10551,9 @@ function discoverEstateOps(projectDirs) {
10244
10551
  var RECEIPT_READ_FN = "receiptRead";
10245
10552
  var RECEIPT_WRITE_FN = "receiptWrite";
10246
10553
  var STEP_STATUSES = /* @__PURE__ */ new Set(["ok", "fail", "skipped"]);
10247
- var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10554
+ var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10248
10555
  function asStepRecord(value) {
10249
- if (!isRecord7(value)) return null;
10556
+ if (!isRecord8(value)) return null;
10250
10557
  if (typeof value.phase !== "string" || typeof value.fn !== "string") return null;
10251
10558
  if (typeof value.status !== "string" || !STEP_STATUSES.has(value.status)) return null;
10252
10559
  if (typeof value.durationMs !== "number") return null;
@@ -10261,7 +10568,7 @@ function parseRunProgressLine(line) {
10261
10568
  }
10262
10569
  const one = asStepRecord(parsed);
10263
10570
  if (one) return [one];
10264
- if (isRecord7(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
10571
+ if (isRecord8(parsed) && typeof parsed.op === "string" && Array.isArray(parsed.records)) {
10265
10572
  const records = parsed.records.map(asStepRecord).filter((r) => r !== null);
10266
10573
  return records.length ? records : null;
10267
10574
  }
@@ -10455,11 +10762,11 @@ var RAN = { ok: "ok", fail: "failed", skipped: "skipped" };
10455
10762
  function tookLabel(ms) {
10456
10763
  return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
10457
10764
  }
10458
- function paintPlayhead(ir, join28, gate) {
10459
- if (!join28.byNode.size && !join28.at) return ir;
10765
+ function paintPlayhead(ir, join30, gate) {
10766
+ if (!join30.byNode.size && !join30.at) return ir;
10460
10767
  const nodes = ir.nodes.map((node) => {
10461
- const run3 = join28.byNode.get(node.id);
10462
- const here = join28.at === node.id;
10768
+ const run3 = join30.byNode.get(node.id);
10769
+ const here = join30.at === node.id;
10463
10770
  if (!run3 && !here) return node;
10464
10771
  const isGate = node.attrs?._step === "gate";
10465
10772
  const attrs = { ...node.attrs };
@@ -10474,7 +10781,7 @@ function paintPlayhead(ir, join28, gate) {
10474
10781
  if (here) {
10475
10782
  attrs._playhead = true;
10476
10783
  attrs._status = isGate && gate ? "warn" : "accent";
10477
- attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join28.atReason ?? "here";
10784
+ attrs.run = isGate && gate ? `waiting for approval since ${gate.since}` : join30.atReason ?? "here";
10478
10785
  if (isGate && gate?.description) attrs.gateDescription = gate.description;
10479
10786
  }
10480
10787
  return { ...node, attrs };
@@ -10496,11 +10803,11 @@ function pendingGateCard(op, gate, ir) {
10496
10803
  }
10497
10804
  };
10498
10805
  }
10499
- function playheadNote(state, join28) {
10806
+ function playheadNote(state, join30) {
10500
10807
  if (!state.op) {
10501
10808
  return "No run state: nothing has been run from this behold session, so every step paints as declared.";
10502
10809
  }
10503
- const settled = join28 ? join28.byNode.size : state.records.length;
10810
+ const settled = join30 ? join30.byNode.size : state.records.length;
10504
10811
  const bits = [];
10505
10812
  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`;
10506
10813
  if (state.status === "running") {
@@ -10516,9 +10823,9 @@ function playheadNote(state, join28) {
10516
10823
  } else if (state.gateNote) {
10517
10824
  bits.push(`gate state unavailable \u2014 ${state.gateNote}`);
10518
10825
  }
10519
- if (join28?.unplaced.length) {
10826
+ if (join30?.unplaced.length) {
10520
10827
  bits.push(
10521
- `${join28.unplaced.length} record${join28.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join28.unplaced.length === 1 ? "is" : "are"} not painted`
10828
+ `${join30.unplaced.length} record${join30.unplaced.length === 1 ? "" : "s"} matched no declared step and ${join30.unplaced.length === 1 ? "is" : "are"} not painted`
10522
10829
  );
10523
10830
  }
10524
10831
  if (!bits.length) return `${head}.`;
@@ -11190,24 +11497,24 @@ var OpRunner = class {
11190
11497
 
11191
11498
  // src/substrates.ts
11192
11499
  import { spawn as spawn5 } from "node:child_process";
11193
- import { existsSync as existsSync13, readFileSync as readFileSync15 } from "node:fs";
11194
- import { join as join19 } from "node:path";
11500
+ import { existsSync as existsSync14, readFileSync as readFileSync15 } from "node:fs";
11501
+ import { join as join20 } from "node:path";
11195
11502
  import { platform } from "node:os";
11196
11503
  var DOCKER_DOWN_DETAIL = "docker is down";
11197
11504
  function probe(cmd, args) {
11198
- return new Promise((resolve15) => {
11505
+ return new Promise((resolve19) => {
11199
11506
  let out = "";
11200
11507
  let proc;
11201
11508
  try {
11202
11509
  proc = spawn5(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
11203
11510
  } catch {
11204
- resolve15({ code: 127, out: "" });
11511
+ resolve19({ code: 127, out: "" });
11205
11512
  return;
11206
11513
  }
11207
11514
  proc.stdout.on("data", (d) => out += d);
11208
11515
  proc.stderr.on("data", (d) => out += d);
11209
- proc.on("error", () => resolve15({ code: 127, out }));
11210
- proc.on("close", (code) => resolve15({ code: code ?? 1, out }));
11516
+ proc.on("error", () => resolve19({ code: 127, out }));
11517
+ proc.on("close", (code) => resolve19({ code: code ?? 1, out }));
11211
11518
  });
11212
11519
  }
11213
11520
  async function dockerAvailable(run3) {
@@ -11220,11 +11527,11 @@ async function dockerRunning(run3, nameFilter) {
11220
11527
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
11221
11528
  }
11222
11529
  function scriptBringUp(projectDir, relPath, label) {
11223
- return existsSync13(join19(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
11530
+ return existsSync14(join20(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
11224
11531
  }
11225
11532
  function projectLexicons(projectDir) {
11226
11533
  try {
11227
- const src = readFileSync15(join19(projectDir, "chant.config.ts"), "utf-8");
11534
+ const src = readFileSync15(join20(projectDir, "chant.config.ts"), "utf-8");
11228
11535
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
11229
11536
  if (!m) return [];
11230
11537
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -11276,7 +11583,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
11276
11583
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
11277
11584
  ];
11278
11585
  for (const [name, label, marker, script] of forges) {
11279
- if (!existsSync13(join19(projectDir, marker))) continue;
11586
+ if (!existsSync14(join20(projectDir, marker))) continue;
11280
11587
  const c = docker ? await dockerRunning(run3, name) : [];
11281
11588
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
11282
11589
  subs.push({
@@ -11310,7 +11617,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
11310
11617
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
11311
11618
  });
11312
11619
  }
11313
- if (existsSync13(join19(projectDir, ".github", "workflows"))) {
11620
+ if (existsSync14(join20(projectDir, ".github", "workflows"))) {
11314
11621
  const gh = await run3("gh", ["auth", "status"]);
11315
11622
  const ready = gh.code === 0;
11316
11623
  subs.push({
@@ -11323,7 +11630,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
11323
11630
  if (lexicons.includes("temporal")) {
11324
11631
  let hasProfiles = false;
11325
11632
  try {
11326
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync15(join19(projectDir, "chant.config.ts"), "utf-8"));
11633
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync15(join20(projectDir, "chant.config.ts"), "utf-8"));
11327
11634
  } catch {
11328
11635
  }
11329
11636
  subs.push({
@@ -11467,7 +11774,7 @@ function statusVocabulary(kinds) {
11467
11774
 
11468
11775
  // src/behaviour.ts
11469
11776
  import { readFileSync as readFileSync16 } from "node:fs";
11470
- import { join as join20 } from "node:path";
11777
+ import { join as join21 } from "node:path";
11471
11778
  var rec5 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
11472
11779
  var str5 = (v) => typeof v === "string" && v.trim().length > 0 ? v : void 0;
11473
11780
  var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
@@ -11570,6 +11877,7 @@ function validateBehaviourReport(v) {
11570
11877
  if (d.entities !== void 0 && !rec5(d.entities)) return bad("entities is not an object");
11571
11878
  return { ok: true, value: { meta: meta.value, entities } };
11572
11879
  }
11880
+ var FIRST_TOTAL_SHOWN = "two members stated an estate total; the first is shown";
11573
11881
  var behaviourFileName = (env) => `behaviour.${env}.json`;
11574
11882
  var realRead = (path) => {
11575
11883
  try {
@@ -11612,7 +11920,7 @@ function attachBehaviour(ir, members, env, readFile = realRead) {
11612
11920
  const label = m.name ?? "the project";
11613
11921
  const slice = sliceOf(ir.nodes, m.name);
11614
11922
  const inline = slice.filter((n) => n.attrs?.[BEHAVIOUR_ATTR] !== void 0);
11615
- if (inline.length > 0) {
11923
+ if (inline.length > 0 || m.meta !== void 0) {
11616
11924
  sources.set(label, "attrs");
11617
11925
  for (const n of inline) {
11618
11926
  const v = validateBehaviourBlock(n.attrs[BEHAVIOUR_ATTR]);
@@ -11631,7 +11939,7 @@ function attachBehaviour(ir, members, env, readFile = realRead) {
11631
11939
  }
11632
11940
  const file = behaviourFileName(env);
11633
11941
  looked.push(`${label}/${file}`);
11634
- const raw = readFile(join20(m.dir, file));
11942
+ const raw = readFile(join21(m.dir, file));
11635
11943
  if (raw === void 0) {
11636
11944
  sources.set(label, "none");
11637
11945
  continue;
@@ -11698,7 +12006,7 @@ function attachBehaviour(ir, members, env, readFile = realRead) {
11698
12006
  const engineTotal = metas.map((m) => m.meta.total).find((t) => !!t);
11699
12007
  if (engineTotal) {
11700
12008
  meta.total = engineTotal;
11701
- if (metas.filter((m) => m.meta.total).length > 1) diagnostics.push("two members stated an estate total; the first is shown");
12009
+ if (metas.filter((m) => m.meta.total).length > 1) diagnostics.push(FIRST_TOTAL_SHOWN);
11702
12010
  } else {
11703
12011
  const sum = sumOver(ir.nodes, (cs) => diagnostics.push(`no estate sum: the figures are in ${cs.join(" and ")} and behold converts no currency`));
11704
12012
  if (sum) meta.sum = sum;
@@ -11722,6 +12030,239 @@ function attachBehaviour(ir, members, env, readFile = realRead) {
11722
12030
  return meta;
11723
12031
  }
11724
12032
 
12033
+ // src/behaviour-delta.ts
12034
+ init_chant();
12035
+ import { basename as basename2, resolve as resolve12 } from "node:path";
12036
+ init_choudoufu_member();
12037
+ init_terraform_member();
12038
+ var DECLARED_ATTR = "_behaviourDeclared";
12039
+ function trafficFor(query, config) {
12040
+ const asked = query.get("traffic")?.trim();
12041
+ return asked || config.behaviour?.traffic || void 0;
12042
+ }
12043
+ function reportFromIr(ir, strip3 = "") {
12044
+ const meta = ir.meta?._behaviour;
12045
+ const entities = {};
12046
+ for (const n of ir.nodes) {
12047
+ const block = n.attrs?._behaviour;
12048
+ if (block === void 0) continue;
12049
+ entities[strip3 && n.id.startsWith(strip3) ? n.id.slice(strip3.length) : n.id] = block;
12050
+ }
12051
+ if (meta === void 0 && Object.keys(entities).length === 0) return void 0;
12052
+ return { meta, entities };
12053
+ }
12054
+ function choudoufuPredictVia(read, readerState = terraformReaderState) {
12055
+ return {
12056
+ tool: () => `${TERRAFORM_LEXICON_PKG}\0predict\0${readerState().lexicon.version ?? "absent"}`,
12057
+ read: async (dir, opts) => {
12058
+ const state = readerState();
12059
+ if (state.refusal) throw new TerraformReadError(state.refusal, dir);
12060
+ const own = [{ name: basename2(dir), dir: "." }];
12061
+ const project = writeTerraformScratchProject(dir, { roots: own, skipped: [] }, void 0, "chdf");
12062
+ const spawnEnv = choudoufuSpawnOverride();
12063
+ const ir = await read(project, {
12064
+ traffic: opts.traffic,
12065
+ ...opts.live ? { live: true, env: opts.env } : {},
12066
+ ...spawnEnv ? { spawnEnv } : {}
12067
+ });
12068
+ return Object.assign(ir, { behaviourRoot: own[0].name });
12069
+ }
12070
+ };
12071
+ }
12072
+ var rootPrefix = (ir) => {
12073
+ const root = ir.behaviourRoot;
12074
+ return root ? `${root}/` : "";
12075
+ };
12076
+ var said2 = (err) => (err instanceof Error ? err.message : String(err)).split("\n")[0];
12077
+ async function predictMember(member, env, traffic, deps, fresh = false) {
12078
+ const dir = resolve12(member.dir);
12079
+ if (member.kind === "terraform") {
12080
+ try {
12081
+ const declared = reportFromIr(await memberIr(dir, { traffic }, terraformVia));
12082
+ return declared ? { declared } : {};
12083
+ } catch (err) {
12084
+ return { note: `declared prediction unavailable: ${said2(err)}` };
12085
+ }
12086
+ }
12087
+ if (member.kind === "choudoufu") {
12088
+ const via = choudoufuPredictVia(deps.read, deps.readerState);
12089
+ const notes = [];
12090
+ const side = async (from) => {
12091
+ try {
12092
+ const ir = from === "live" ? await overlayIr(dir, { traffic, live: true, env }, via, fresh) : await memberIr(dir, { traffic }, via);
12093
+ return reportFromIr(ir, rootPrefix(ir));
12094
+ } catch (err) {
12095
+ notes.push(`${from} prediction unavailable: ${said2(err)}`);
12096
+ return void 0;
12097
+ }
12098
+ };
12099
+ const [live2, declared] = await Promise.all([env === void 0 ? void 0 : side("live"), side("declared")]);
12100
+ return { ...live2 ? { live: live2 } : {}, ...declared ? { declared } : {}, ...notes.length ? { note: notes.join("; ") } : {} };
12101
+ }
12102
+ if (member.kind === "chant" && memberTakesTraffic(dir, "chant")) {
12103
+ try {
12104
+ const declared = reportFromIr(await memberIr(dir, { traffic }));
12105
+ return declared ? { declared } : {};
12106
+ } catch (err) {
12107
+ return { note: `declared prediction unavailable: ${said2(err)}` };
12108
+ }
12109
+ }
12110
+ if (member.kind === "chant") {
12111
+ return { note: `this member's chant predates \`graph --traffic\` (${TRAFFIC_FLOOR}), so nothing was predicted for it` };
12112
+ }
12113
+ return {};
12114
+ }
12115
+ var idFor = (name, address) => name === void 0 ? address : `${name}/${address}`;
12116
+ function paintLive(ir, name, report, diagnostics) {
12117
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
12118
+ for (const [address, block] of Object.entries(report.entities)) {
12119
+ const node = byId.get(idFor(name, address));
12120
+ if (!node) {
12121
+ diagnostics.push(`${name ?? "the project"}: the live prediction prices ${address}, which this overlay has no entity for`);
12122
+ continue;
12123
+ }
12124
+ node.attrs = { ...node.attrs, _behaviour: block };
12125
+ }
12126
+ return report.meta;
12127
+ }
12128
+ var round62 = (n) => Math.round(n * 1e6) / 1e6;
12129
+ function figureOf(meta, blocks, onMixed) {
12130
+ if (meta?.total) return { perHour: meta.total.perHour, currency: meta.total.currency, basis: "total" };
12131
+ if (!blocks.length) return void 0;
12132
+ const currencies = [...new Set(blocks.map((b) => b.cost.currency))].sort();
12133
+ if (currencies.length > 1) {
12134
+ onMixed(currencies);
12135
+ return void 0;
12136
+ }
12137
+ return { perHour: round62(blocks.reduce((sum, b) => sum + b.cost.perHour, 0)), currency: currencies[0], basis: "sum" };
12138
+ }
12139
+ function deltaOf(live2, declared, label, diagnostics) {
12140
+ if (!live2 || !declared) return void 0;
12141
+ if (live2.currency !== declared.currency) {
12142
+ diagnostics.push(`no delta for ${label}: the live figure is in ${live2.currency} and the declared one in ${declared.currency}`);
12143
+ return void 0;
12144
+ }
12145
+ if (live2.basis !== declared.basis) {
12146
+ diagnostics.push(`no delta for ${label}: one side is the engine's own total and the other is behold's sum, and those are not the same kind of figure`);
12147
+ return void 0;
12148
+ }
12149
+ return { perHour: round62(live2.perHour - declared.perHour), currency: live2.currency };
12150
+ }
12151
+ function attachBehaviourDelta(ir, members, behaviour) {
12152
+ const diagnostics = [...behaviour.diagnostics ?? []];
12153
+ const perMember = {};
12154
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
12155
+ for (const m of members) {
12156
+ const label = m.name ?? "the project";
12157
+ if (m.prediction.note) diagnostics.push(`${label}: ${m.prediction.note}`);
12158
+ const slice = m.name === void 0 ? ir.nodes : ir.nodes.filter((n) => n.id.startsWith(`${m.name}/`));
12159
+ const liveMeta = m.liveMeta === void 0 ? void 0 : validateBehaviourMeta(m.liveMeta);
12160
+ const liveBlocks = slice.map((n) => n.attrs?._behaviour).filter((b) => !!b);
12161
+ const live3 = behaviour.refusal ? void 0 : figureOf(liveMeta?.ok ? liveMeta.value : void 0, liveBlocks, (cs) => diagnostics.push(`no live figure for ${label}: the figures are in ${cs.join(" and ")}`));
12162
+ let declared2;
12163
+ const report = m.prediction.declared;
12164
+ if (report) {
12165
+ const meta = validateBehaviourMeta(report.meta);
12166
+ if (!meta.ok) diagnostics.push(`${label}: dropped the declared prediction's meta \u2014 ${meta.reason}`);
12167
+ else if (meta.value.refusal) diagnostics.push(`${label}: the declared prediction was refused \u2014 ${meta.value.refusal.reason}`);
12168
+ else {
12169
+ const blocks = [];
12170
+ for (const [address, raw] of Object.entries(report.entities)) {
12171
+ const v = validateBehaviourBlock(raw);
12172
+ if (!v.ok) {
12173
+ diagnostics.push(`dropped the declared behaviour block on ${idFor(m.name, address)}: ${v.reason}`);
12174
+ continue;
12175
+ }
12176
+ blocks.push(v.value);
12177
+ const node = byId.get(idFor(m.name, address));
12178
+ if (node) node.attrs = { ...node.attrs, [DECLARED_ATTR]: v.value };
12179
+ }
12180
+ declared2 = figureOf(meta.value, blocks, (cs) => diagnostics.push(`no declared figure for ${label}: the figures are in ${cs.join(" and ")}`));
12181
+ }
12182
+ }
12183
+ const delta2 = deltaOf(live3, declared2, label, diagnostics);
12184
+ if (live3 || declared2) perMember[label] = { ...live3 ? { live: live3 } : {}, ...declared2 ? { declared: declared2 } : {}, ...delta2 ? { delta: delta2 } : {} };
12185
+ }
12186
+ const rows = Object.values(perMember);
12187
+ const addUp = (side) => {
12188
+ const figures = rows.map((r) => r[side]).filter((f) => !!f);
12189
+ if (!figures.length || figures.length !== rows.length) return void 0;
12190
+ if (new Set(figures.map((f) => f.currency)).size > 1) return void 0;
12191
+ const basis = figures.every((f) => f.basis === "total") && figures.length === 1 ? "total" : "sum";
12192
+ return { perHour: round62(figures.reduce((sum, f) => sum + f.perHour, 0)), currency: figures[0].currency, basis };
12193
+ };
12194
+ const live2 = addUp("live");
12195
+ const declared = addUp("declared");
12196
+ const allDeltas = rows.length > 0 && rows.every((r) => r.delta);
12197
+ const delta = live2 && declared && allDeltas ? { perHour: round62(live2.perHour - declared.perHour), currency: live2.currency } : void 0;
12198
+ const out = { ...behaviour };
12199
+ const stated = rows.filter((r) => r.live?.basis === "total").length;
12200
+ if (live2 && stated > 1) {
12201
+ delete out.total;
12202
+ const at = diagnostics.indexOf(FIRST_TOTAL_SHOWN);
12203
+ if (at >= 0) diagnostics.splice(at, 1);
12204
+ }
12205
+ if (live2) out.live = live2;
12206
+ if (declared) out.declared = declared;
12207
+ if (delta) out.delta = delta;
12208
+ if (rows.length && members.some((m) => m.name !== void 0)) out.members = perMember;
12209
+ if (diagnostics.length) out.diagnostics = diagnostics;
12210
+ else delete out.diagnostics;
12211
+ return out;
12212
+ }
12213
+ function attachDeclaredBehaviour(ir, members) {
12214
+ const diagnostics = [];
12215
+ const perMember = {};
12216
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
12217
+ let first;
12218
+ for (const m of members) {
12219
+ const label = m.name ?? "the project";
12220
+ if (m.prediction.note) diagnostics.push(`${label}: ${m.prediction.note}`);
12221
+ const report = m.prediction.declared;
12222
+ if (!report) continue;
12223
+ const meta = validateBehaviourMeta(report.meta);
12224
+ if (!meta.ok) {
12225
+ diagnostics.push(`${label}: dropped the declared prediction's meta \u2014 ${meta.reason}`);
12226
+ continue;
12227
+ }
12228
+ if (meta.value.refusal) {
12229
+ for (const n of ir.nodes) if (n.attrs?._behaviour !== void 0) delete n.attrs._behaviour;
12230
+ return { refusal: meta.value.refusal };
12231
+ }
12232
+ first ??= meta.value;
12233
+ const blocks = [];
12234
+ for (const [address, raw] of Object.entries(report.entities)) {
12235
+ const v = validateBehaviourBlock(raw);
12236
+ if (!v.ok) {
12237
+ diagnostics.push(`dropped the behaviour block on ${idFor(m.name, address)}: ${v.reason}`);
12238
+ continue;
12239
+ }
12240
+ blocks.push(v.value);
12241
+ const node = byId.get(idFor(m.name, address));
12242
+ if (node) node.attrs = { ...node.attrs, _behaviour: v.value };
12243
+ }
12244
+ const declared2 = figureOf(meta.value, blocks, (cs) => diagnostics.push(`no declared figure for ${label}: the figures are in ${cs.join(" and ")}`));
12245
+ if (declared2) perMember[label] = { declared: declared2 };
12246
+ }
12247
+ if (!first) {
12248
+ return {
12249
+ absent: "no behaviour block: a traffic level was given and no member's declared prediction came back",
12250
+ ...diagnostics.length ? { diagnostics } : {}
12251
+ };
12252
+ }
12253
+ const figures = Object.values(perMember).map((r) => r.declared);
12254
+ const oneCurrency = new Set(figures.map((f) => f.currency)).size === 1;
12255
+ const declared = figures.length && oneCurrency ? { perHour: round62(figures.reduce((sum, f) => sum + f.perHour, 0)), currency: figures[0].currency, basis: figures.length === 1 ? figures[0].basis : "sum" } : void 0;
12256
+ return {
12257
+ ...first.engine ? { engine: first.engine } : {},
12258
+ ...first.version ? { version: first.version } : {},
12259
+ ...first.at ? { at: first.at } : {},
12260
+ ...declared ? { declared } : {},
12261
+ ...members.some((m) => m.name !== void 0) && figures.length ? { members: perMember } : {},
12262
+ ...diagnostics.length ? { diagnostics } : {}
12263
+ };
12264
+ }
12265
+
11725
12266
  // src/estate-edges.ts
11726
12267
  import { statSync as statSync7 } from "node:fs";
11727
12268
  var rec6 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
@@ -11834,29 +12375,29 @@ init_choudoufu_live();
11834
12375
  init_choudoufu_member();
11835
12376
  import { mkdtempSync, rmSync as rmSync3 } from "node:fs";
11836
12377
  import { tmpdir as tmpdir2 } from "node:os";
11837
- import { join as join21 } from "node:path";
11838
- var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11839
- var refuse6 = (error, remedy) => ({ ok: false, refusal: { error, code: "choudoufu-live-check", remedy } });
12378
+ import { join as join22 } from "node:path";
12379
+ var isRecord9 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
12380
+ var refuse7 = (error, remedy) => ({ ok: false, refusal: { error, code: "choudoufu-live-check", remedy } });
11840
12381
  var CREDS2 = "Give behold the ambient read-only AWS credentials the estate's `plan` needs (AWS_PROFILE, or AWS_ENDPOINT_URL for an emulator), then re-check live with plan.";
11841
- function said2(run3, fallback) {
12382
+ function said3(run3, fallback) {
11842
12383
  const lines = stripAnsiLines(run3.stderr);
11843
12384
  return lines.find((l) => /^Error:/.test(l)) ?? lines[0] ?? fallback;
11844
12385
  }
11845
12386
  function parsePlanShow(run3, dir) {
11846
- if (run3.code === 127) return refuse6("choudoufu is not on PATH.", "Install choudoufu and put it on PATH.");
12387
+ if (run3.code === 127) return refuse7("choudoufu is not on PATH.", "Install choudoufu and put it on PATH.");
11847
12388
  const text = run3.stdout.trim();
11848
- if (!text) return refuse6(`choudoufu show could not read the plan for ${dir}: ${said2(run3, `exit ${run3.code}`)}`, CREDS2);
12389
+ if (!text) return refuse7(`choudoufu show could not read the plan for ${dir}: ${said3(run3, `exit ${run3.code}`)}`, CREDS2);
11849
12390
  let json;
11850
12391
  try {
11851
12392
  json = JSON.parse(text);
11852
12393
  } catch {
11853
- return refuse6(`choudoufu show printed something that is not JSON for ${dir}: ${said2(run3, "unparseable stdout")}`, CREDS2);
12394
+ return refuse7(`choudoufu show printed something that is not JSON for ${dir}: ${said3(run3, "unparseable stdout")}`, CREDS2);
11854
12395
  }
11855
- if (!isRecord8(json) || typeof json.format_version !== "string") {
11856
- return refuse6("That is not a plan document \u2014 no `format_version`.", CREDS2);
12396
+ if (!isRecord9(json) || typeof json.format_version !== "string") {
12397
+ return refuse7("That is not a plan document \u2014 no `format_version`.", CREDS2);
11857
12398
  }
11858
12399
  if (json.resource_changes !== void 0 && json.resource_changes !== null && !Array.isArray(json.resource_changes)) {
11859
- return refuse6("That plan document's `resource_changes` is not a list.", CREDS2);
12400
+ return refuse7("That plan document's `resource_changes` is not a list.", CREDS2);
11860
12401
  }
11861
12402
  return { ok: true, doc: json };
11862
12403
  }
@@ -11864,9 +12405,9 @@ var KNOWN_AFTER_APPLY = "(known after apply)";
11864
12405
  var SENSITIVE = "(sensitive)";
11865
12406
  var flagged = (tree, key) => {
11866
12407
  if (tree === true) return true;
11867
- if (!isRecord8(tree)) return false;
12408
+ if (!isRecord9(tree)) return false;
11868
12409
  const v = tree[key];
11869
- return v === true || isRecord8(v) && Object.values(v).some((x) => x === true) || Array.isArray(v) && v.some((x) => x === true);
12410
+ return v === true || isRecord9(v) && Object.values(v).some((x) => x === true) || Array.isArray(v) && v.some((x) => x === true);
11870
12411
  };
11871
12412
  function same(a, b) {
11872
12413
  if (a === b) return true;
@@ -11874,7 +12415,7 @@ function same(a, b) {
11874
12415
  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
11875
12416
  return a.every((x, i) => same(x, b[i]));
11876
12417
  }
11877
- if (isRecord8(a) && isRecord8(b)) {
12418
+ if (isRecord9(a) && isRecord9(b)) {
11878
12419
  const ka = Object.keys(a);
11879
12420
  const kb = Object.keys(b);
11880
12421
  if (ka.length !== kb.length) return false;
@@ -11885,10 +12426,10 @@ function same(a, b) {
11885
12426
  function planDrift(doc) {
11886
12427
  const out = /* @__PURE__ */ new Map();
11887
12428
  for (const rc of doc.resource_changes ?? []) {
11888
- if (!rc || typeof rc.address !== "string" || !isRecord8(rc.change)) continue;
12429
+ if (!rc || typeof rc.address !== "string" || !isRecord9(rc.change)) continue;
11889
12430
  const { actions, before, after } = rc.change;
11890
12431
  if (!Array.isArray(actions) || actions.every((a) => a === "no-op")) continue;
11891
- if (!isRecord8(before) || !isRecord8(after)) continue;
12432
+ if (!isRecord9(before) || !isRecord9(after)) continue;
11892
12433
  const changes = [];
11893
12434
  for (const key of [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
11894
12435
  const unknownAfter = flagged(rc.change.after_unknown, key);
@@ -11908,14 +12449,14 @@ var planArgv = (out) => ({
11908
12449
  show: ["show", "-json", out]
11909
12450
  });
11910
12451
  async function readChoudoufuPlan(dir, run3 = captureChoudoufu) {
11911
- const scratch = mkdtempSync(join21(tmpdir2(), "behold-choudoufu-plan-"));
11912
- const file = join21(scratch, "plan.tfplan");
12452
+ const scratch = mkdtempSync(join22(tmpdir2(), "behold-choudoufu-plan-"));
12453
+ const file = join22(scratch, "plan.tfplan");
11913
12454
  const argv = planArgv(file);
11914
12455
  try {
11915
12456
  const planRun = await run3(argv.plan, dir);
11916
12457
  if (planRun.code === 127) throw new ChoudoufuReadError({ error: "choudoufu is not on PATH.", code: "choudoufu-live-check", remedy: "Install choudoufu and put it on PATH." }, dir);
11917
12458
  if (planRun.code !== 0) {
11918
- throw new ChoudoufuReadError({ error: `choudoufu plan could not plan ${dir}: ${said2(planRun, `exit ${planRun.code}`)}`, code: "choudoufu-live-check", remedy: CREDS2 }, dir);
12459
+ throw new ChoudoufuReadError({ error: `choudoufu plan could not plan ${dir}: ${said3(planRun, `exit ${planRun.code}`)}`, code: "choudoufu-live-check", remedy: CREDS2 }, dir);
11919
12460
  }
11920
12461
  const parsed = parsePlanShow(await run3(argv.show, dir), dir);
11921
12462
  if (!parsed.ok) throw new ChoudoufuReadError(parsed.refusal, dir);
@@ -11941,16 +12482,16 @@ init_choudoufu_refs();
11941
12482
  // src/choudoufu-moves.ts
11942
12483
  init_choudoufu_member();
11943
12484
  init_choudoufu_live();
11944
- import { existsSync as existsSync14, readFileSync as readFileSync17 } from "node:fs";
11945
- import { join as join22, relative as relative5 } from "node:path";
11946
- var isRecord9 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11947
- var refuse7 = (error, remedy) => ({ ok: false, refusal: { error, code: "choudoufu-live-check", remedy } });
12485
+ import { existsSync as existsSync15, readFileSync as readFileSync17 } from "node:fs";
12486
+ import { join as join23, relative as relative5 } from "node:path";
12487
+ var isRecord10 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
12488
+ var refuse8 = (error, remedy) => ({ ok: false, refusal: { error, code: "choudoufu-live-check", remedy } });
11948
12489
  var HOW_TO_GET_ONE2 = "A plan is the live-mv workbench's carve.json: `{from, estates[], moves[{address, from, to}]}` \u2014 the workbench's plan phase writes one, or write it by hand.";
11949
12490
  function parseCarvePlan(value) {
11950
- if (!isRecord9(value)) return refuse7("That file is not a move plan \u2014 its top level is not a JSON object.", HOW_TO_GET_ONE2);
11951
- if (!Array.isArray(value.moves)) return refuse7("That JSON is not a move plan \u2014 it has no `moves` array.", HOW_TO_GET_ONE2);
11952
- const bad2 = value.moves.findIndex((m) => !isRecord9(m) || typeof m.address !== "string" || typeof m.from !== "string" || typeof m.to !== "string");
11953
- if (bad2 >= 0) return refuse7(`moves[${bad2}] is not a move (needs string \`address\`, \`from\` and \`to\`).`, HOW_TO_GET_ONE2);
12491
+ if (!isRecord10(value)) return refuse8("That file is not a move plan \u2014 its top level is not a JSON object.", HOW_TO_GET_ONE2);
12492
+ if (!Array.isArray(value.moves)) return refuse8("That JSON is not a move plan \u2014 it has no `moves` array.", HOW_TO_GET_ONE2);
12493
+ const bad2 = value.moves.findIndex((m) => !isRecord10(m) || typeof m.address !== "string" || typeof m.from !== "string" || typeof m.to !== "string");
12494
+ if (bad2 >= 0) return refuse8(`moves[${bad2}] is not a move (needs string \`address\`, \`from\` and \`to\`).`, HOW_TO_GET_ONE2);
11954
12495
  const moves = value.moves;
11955
12496
  return {
11956
12497
  ok: true,
@@ -11967,34 +12508,34 @@ function readCarvePlan(path, readFile = (p) => readFileSync17(p, "utf8")) {
11967
12508
  try {
11968
12509
  text = readFile(path);
11969
12510
  } catch (err) {
11970
- return refuse7(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_GET_ONE2);
12511
+ return refuse8(`Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_GET_ONE2);
11971
12512
  }
11972
12513
  try {
11973
12514
  return parseCarvePlan(JSON.parse(text));
11974
12515
  } catch (err) {
11975
- return refuse7(`${path} is not JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_GET_ONE2);
12516
+ return refuse8(`${path} is not JSON: ${err instanceof Error ? err.message : String(err)}`, HOW_TO_GET_ONE2);
11976
12517
  }
11977
12518
  }
11978
12519
  function discoverCarvePlans(memberDirs, base) {
11979
12520
  const out = [];
11980
12521
  for (const dir of memberDirs) {
11981
- const p = join22(dir, "carve.json");
11982
- if (existsSync14(p)) out.push(relative5(base, p));
12522
+ const p = join23(dir, "carve.json");
12523
+ if (existsSync15(p)) out.push(relative5(base, p));
11983
12524
  }
11984
12525
  return out;
11985
12526
  }
11986
12527
  function parseLiveMv(run3, what) {
11987
- if (run3.code === 127) return refuse7("choudoufu is not on PATH.", "Install choudoufu and put it on PATH.");
12528
+ if (run3.code === 127) return refuse8("choudoufu is not on PATH.", "Install choudoufu and put it on PATH.");
11988
12529
  const text = run3.stdout.trim();
11989
- if (!text) return refuse7(`choudoufu live-mv printed no document for ${what} (exit ${run3.code}).`, "Run the line by hand to see choudoufu's own words.");
12530
+ if (!text) return refuse8(`choudoufu live-mv printed no document for ${what} (exit ${run3.code}).`, "Run the line by hand to see choudoufu's own words.");
11990
12531
  let json;
11991
12532
  try {
11992
12533
  json = JSON.parse(text);
11993
12534
  } catch {
11994
- return refuse7(`choudoufu live-mv printed something that is not JSON for ${what}.`, "Run the line by hand to see choudoufu's own words.");
12535
+ return refuse8(`choudoufu live-mv printed something that is not JSON for ${what}.`, "Run the line by hand to see choudoufu's own words.");
11995
12536
  }
11996
- if (!isRecord9(json) || !isRecord9(json.from) || !isRecord9(json.to) || typeof json.dry_run !== "boolean") {
11997
- return refuse7(`That is not a live-mv document (${what}).`, "Upgrade choudoufu to 0.16.0 or newer.");
12537
+ if (!isRecord10(json) || !isRecord10(json.from) || !isRecord10(json.to) || typeof json.dry_run !== "boolean") {
12538
+ return refuse8(`That is not a live-mv document (${what}).`, "Upgrade choudoufu to 0.16.0 or newer.");
11998
12539
  }
11999
12540
  return { ok: true, doc: json };
12000
12541
  }
@@ -12070,6 +12611,142 @@ async function moveReceipt(plan, members, run3 = captureChoudoufu) {
12070
12611
  };
12071
12612
  }
12072
12613
 
12614
+ // src/workspace-gates.ts
12615
+ init_chant();
12616
+ import { resolve as resolve13 } from "node:path";
12617
+ var isRecord11 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
12618
+ var str7 = (v) => typeof v === "string" && v ? v : null;
12619
+ function gateKey(member, op, gate) {
12620
+ return `${member}/${op}/${gate}`;
12621
+ }
12622
+ function approveArgs(g) {
12623
+ return ["approve", g.op, g.gate, ...g.env ? ["--env", g.env] : [], ...g.planDigest ? ["--plan", g.planDigest] : []];
12624
+ }
12625
+ function parseWorkspaceStatus(text, root) {
12626
+ let doc;
12627
+ try {
12628
+ doc = JSON.parse(text);
12629
+ } catch {
12630
+ return { ok: false, refusal: { error: "chant workspace status printed something that is not JSON.", code: "workspace-status", remedy: "Run `chant workspace status <env> --json` in the workspace root to see what it says." } };
12631
+ }
12632
+ if (!isRecord11(doc)) return { ok: false, refusal: { error: "chant workspace status printed JSON that is not an object.", code: "workspace-status", remedy: "Run `chant workspace status <env> --json` in the workspace root." } };
12633
+ const contract = contractRefusal(doc, "status");
12634
+ if (contract) return { ok: false, refusal: contract };
12635
+ if (isRecord11(doc.error)) {
12636
+ const code = String(doc.error.code ?? "unknown");
12637
+ return { ok: false, refusal: { error: `chant could not read the workspace's status: ${code}: ${String(doc.error.message ?? "")}`, code, remedy: "`chant workspace check` names the problem and where it is." } };
12638
+ }
12639
+ const gates = [];
12640
+ for (const m of Array.isArray(doc.members) ? doc.members : []) {
12641
+ if (!isRecord11(m) || typeof m.name !== "string") continue;
12642
+ const dir = typeof m.dir === "string" ? m.dir === "." ? resolve13(root) : resolve13(root, m.dir) : resolve13(root);
12643
+ for (const g of Array.isArray(m.gates) ? m.gates : []) {
12644
+ if (!isRecord11(g)) continue;
12645
+ if (g.state !== void 0 && g.state !== "pending") continue;
12646
+ const op = str7(g.component);
12647
+ const gate = str7(g.name);
12648
+ if (!op || !gate) continue;
12649
+ const env = str7(g.env);
12650
+ const planDigest = str7(g.planDigest);
12651
+ const approvals = (Array.isArray(g.approvals) ? g.approvals : []).filter(isRecord11).map((a) => ({
12652
+ principal: String(a.principal ?? "unknown"),
12653
+ at: str7(a.at),
12654
+ channel: str7(a.channel)
12655
+ }));
12656
+ gates.push({
12657
+ key: gateKey(m.name, op, gate),
12658
+ member: m.name,
12659
+ dir,
12660
+ op,
12661
+ gate,
12662
+ env,
12663
+ planDigest,
12664
+ approvals,
12665
+ needed: typeof g.needed === "number" ? g.needed : 1,
12666
+ recordedAt: str7(g.recordedAt),
12667
+ expiresAt: str7(g.expiresAt),
12668
+ signed: isRecord11(g.signed),
12669
+ // status's line already carries --sign for a signed gate; only the
12670
+ // line built here needs it added.
12671
+ approve: str7(g.approve) ?? ["chant", ...approveArgs({ op, gate, env, planDigest }), ...isRecord11(g.signed) ? ["--sign"] : []].join(" ")
12672
+ });
12673
+ }
12674
+ }
12675
+ return { ok: true, env: String(doc.env ?? ""), gates };
12676
+ }
12677
+ async function readWorkspaceGates(ws, env) {
12678
+ const run3 = await runChantRaw(["workspace", "status", env, "--json"], ws.root);
12679
+ if (!run3.stdout.trim()) {
12680
+ return {
12681
+ ok: false,
12682
+ refusal: {
12683
+ error: `chant workspace status ${env} failed in ${ws.root} (exit ${run3.code}): ${run3.stderr.trim().split("\n").slice(-3).join(" ")}`,
12684
+ code: "workspace-status",
12685
+ remedy: "Run `chant workspace status <env>` in the workspace root."
12686
+ }
12687
+ };
12688
+ }
12689
+ return parseWorkspaceStatus(run3.stdout, ws.root);
12690
+ }
12691
+ function isEnvName(env) {
12692
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(env);
12693
+ }
12694
+ function localApprover(env = process.env) {
12695
+ return env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? env.USER ?? "unknown";
12696
+ }
12697
+
12698
+ // src/request-guard.ts
12699
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
12700
+ var SAFE = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
12701
+ function hostName(hostport) {
12702
+ const h = hostport.trim().toLowerCase();
12703
+ if (h.startsWith("[")) {
12704
+ const end = h.indexOf("]");
12705
+ return end >= 0 ? h.slice(0, end + 1) : h;
12706
+ }
12707
+ const colon = h.lastIndexOf(":");
12708
+ return colon >= 0 && h.indexOf(":") === colon ? h.slice(0, colon) : h;
12709
+ }
12710
+ function isLoopbackName(name) {
12711
+ return LOOPBACK.has(name) || name.endsWith(".localhost");
12712
+ }
12713
+ function allowedName(name, cfg) {
12714
+ if (isLoopbackName(name)) return true;
12715
+ if (cfg.boundHost && !isWildcard(cfg.boundHost) && hostName(cfg.boundHost) === name) return true;
12716
+ return cfg.allowedHosts.some((h) => hostName(h) === name);
12717
+ }
12718
+ function isWildcard(h) {
12719
+ return h === "0.0.0.0" || h === "::" || h === "[::]";
12720
+ }
12721
+ function guardRequest(req, cfg) {
12722
+ const name = hostName(req.host);
12723
+ if (!allowedName(name, cfg)) {
12724
+ return { ok: false, status: 421, error: `behold doesn't answer to host ${name}. Start it with --allow-host ${name} if a proxy of yours sends that name.` };
12725
+ }
12726
+ if (SAFE.has(req.method.toUpperCase())) return { ok: true };
12727
+ if ((req.secFetchSite || "").toLowerCase() === "cross-site") {
12728
+ return { ok: false, status: 403, error: "a cross-site page can't write to behold" };
12729
+ }
12730
+ if (req.origin && req.origin !== "null") {
12731
+ let originHost;
12732
+ try {
12733
+ originHost = new URL(req.origin).host.toLowerCase();
12734
+ } catch {
12735
+ return { ok: false, status: 403, error: "a write with an unreadable Origin is refused" };
12736
+ }
12737
+ const same2 = originHost === req.host.trim().toLowerCase();
12738
+ if (!same2 && !cfg.allowedHosts.some((h) => hostName(h) === hostName(originHost))) {
12739
+ return { ok: false, status: 403, error: `a page from ${originHost} can't write to behold` };
12740
+ }
12741
+ } else if (req.origin === "null") {
12742
+ return { ok: false, status: 403, error: "a write from an opaque origin is refused" };
12743
+ }
12744
+ return { ok: true };
12745
+ }
12746
+ function allowedHostsFrom(flag = [], env = process.env.BEHOLD_ALLOWED_HOSTS) {
12747
+ return [...flag, ...(env || "").split(",")].map((s) => s.trim()).filter(Boolean);
12748
+ }
12749
+
12073
12750
  // src/server.ts
12074
12751
  init_member_kind();
12075
12752
  init_terraform_member();
@@ -12079,8 +12756,8 @@ init_chant();
12079
12756
  init_member_kind();
12080
12757
  init_choudoufu_member();
12081
12758
  init_project();
12759
+ import { relative as relative6, resolve as resolve14 } from "node:path";
12082
12760
  init_terraform_member();
12083
- import { relative as relative6, resolve as resolve11 } from "node:path";
12084
12761
  init_targets();
12085
12762
  var list2 = (xs) => xs.join(", ");
12086
12763
  var EMPTY_KUBECONFIG = { contexts: /* @__PURE__ */ new Map(), servers: /* @__PURE__ */ new Map() };
@@ -12262,11 +12939,11 @@ async function choudoufuCheck(root, members, probe2) {
12262
12939
  };
12263
12940
  }
12264
12941
  if (!choudoufuMeetsFloor(v)) {
12265
- const said3 = v.forkField ? `choudoufu ${v.version}` : `a choudoufu whose \`version -json\` carries no \`choudoufu_version\` (v0.15.0 or older)`;
12942
+ const said4 = v.forkField ? `choudoufu ${v.version}` : `a choudoufu whose \`version -json\` carries no \`choudoufu_version\` (v0.15.0 or older)`;
12266
12943
  return {
12267
12944
  name: "choudoufu",
12268
12945
  status: "fail",
12269
- detail: `${said3} at ${v.bin} \u2014 below behold's floor ${CHOUDOUFU_FLOOR}, whose \`-json\` documents carry the fields behold reads (schemas, identity, choudoufu_version)`,
12946
+ detail: `${said4} at ${v.bin} \u2014 below behold's floor ${CHOUDOUFU_FLOOR}, whose \`-json\` documents carry the fields behold reads (schemas, identity, choudoufu_version)`,
12270
12947
  fix: `Upgrade choudoufu to ${CHOUDOUFU_FLOOR} or newer (a build from main after 2026-09-08 also carries them).`
12271
12948
  };
12272
12949
  }
@@ -12308,10 +12985,41 @@ function terraformCheck(members, probe2) {
12308
12985
  const reader = `${TERRAFORM_LEXICON_PKG} ${state.lexicon.version || "dev"} (${HCL_PARSER_PKG} ${state.parser.version || "dev"})`;
12309
12986
  return { name: "terraform", status: "pass", detail: `${reader}; ${perMember}` };
12310
12987
  }
12988
+ function workspaceShape(ws) {
12989
+ return { kind: "estate", members: drawnMembers(ws).map((m) => ({ dir: m.dir, kind: m.kind })) };
12990
+ }
12991
+ function workspaceCheck(ws, beholdMembers) {
12992
+ const drawn = drawnMembers(ws);
12993
+ const unreadable = unreadableMembers(ws);
12994
+ const undrawn = undrawnMembers(ws);
12995
+ const parts = [
12996
+ `chant workspace ${ws.name} (${ws.file}, listed by chant ${ws.chant}): ${drawn.length} of ${ws.members.length} members drawn: ${list2(drawn.map((m) => `${m.name} (${m.kind})`))}`,
12997
+ ...unreadable.length ? [`unreadable: ${list2(unreadable.map((m) => `${m.name} ${m.reason.code}: ${m.reason.message}`))}`] : [],
12998
+ ...undrawn.length ? [`listed, not drawn: ${list2(undrawn.map((m) => `${m.name} (${m.kind})`))}`] : [],
12999
+ ...beholdMembers ? [".behold.json also lists members, which are ignored beside the declaration"] : []
13000
+ ];
13001
+ const warn = unreadable.length > 0 || beholdMembers;
13002
+ const fix = [
13003
+ ...unreadable.length ? ["`chant workspace check` says what each unreadable member needs."] : [],
13004
+ ...beholdMembers ? ["Delete `members` from .behold.json; chant.workspace.json lists them now."] : []
13005
+ ].join(" ");
13006
+ return { name: "project", status: warn ? "warn" : "pass", detail: parts.join("; "), ...warn ? { fix } : {} };
13007
+ }
12311
13008
  async function diagnose(dir, probes = {}) {
12312
- const root = resolve11(dir);
12313
- const shape = detectProjectShape(root);
13009
+ const root = resolve14(dir);
12314
13010
  const behold = beholdVersion();
13011
+ const listing = hasDeclaration(root) ? await (probes.readWorkspace ?? readWorkspace)(root) : void 0;
13012
+ if (listing && !listing.ok) {
13013
+ return {
13014
+ behold,
13015
+ dir: root,
13016
+ kind: "estate",
13017
+ ok: false,
13018
+ checks: [{ name: "project", status: "fail", detail: listing.refusal.error, fix: listing.refusal.remedy }]
13019
+ };
13020
+ }
13021
+ const workspace = listing?.ok ? listing.workspace : void 0;
13022
+ const shape = workspace ? workspaceShape(workspace) : detectProjectShape(root);
12315
13023
  const invalid = shape.invalidMembers ?? [];
12316
13024
  const invalidDetail = invalid.map((m) => `${m.dir} \u2014 ${m.invalid}`);
12317
13025
  const kindsFix = `Member kinds this behold reads: ${registeredMemberKinds().join(", ")}. Fix or remove the entry in .behold.json \`members\`.`;
@@ -12337,14 +13045,14 @@ async function diagnose(dir, probes = {}) {
12337
13045
  };
12338
13046
  }
12339
13047
  const estate = shape.kind === "estate";
12340
- const members = (shape.members ?? []).map((m) => ({ ...m, abs: resolve11(root, m.dir) }));
13048
+ const members = (shape.members ?? []).map((m) => ({ ...m, abs: resolve14(root, m.dir) }));
12341
13049
  const targets = estate ? members.map((m) => m.abs) : [root];
12342
13050
  const primary = targets[0];
12343
13051
  const chantTargets = estate ? members.filter((m) => m.kind === "chant").map((m) => m.abs) : [root];
12344
13052
  const memberList = list2(members.map((m) => `${m.dir} (${m.kind})`));
12345
13053
  const membersFrom = shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces";
12346
13054
  const estateDetail = shape.membersFrom === "itself" ? `a ${members[0].kind} member \u2014 the directory itself, no member list` : `estate of ${members.length} members (${membersFrom}): ${memberList}`;
12347
- const projectCheck = estate ? invalid.length ? {
13055
+ const projectCheck = workspace ? workspaceCheck(workspace, !!loadBeholdConfig(root).members) : estate ? invalid.length ? {
12348
13056
  name: "project",
12349
13057
  status: "fail",
12350
13058
  detail: `${estateDetail}; invalid: ${list2(invalidDetail)}`,
@@ -12397,12 +13105,11 @@ function formatReport(report) {
12397
13105
 
12398
13106
  // src/server.ts
12399
13107
  init_read_stats();
12400
- import { shortStackNames as shortStackNames2 } from "@intentius/pinhole";
12401
13108
  init_foreign();
12402
13109
 
12403
13110
  // src/events.ts
12404
- import { watch, existsSync as existsSync15 } from "node:fs";
12405
- import { join as join23 } from "node:path";
13111
+ import { watch, existsSync as existsSync16 } from "node:fs";
13112
+ import { join as join24 } from "node:path";
12406
13113
  var Broadcaster = class {
12407
13114
  listeners = /* @__PURE__ */ new Set();
12408
13115
  subscribe(fn) {
@@ -12420,7 +13127,7 @@ var Broadcaster = class {
12420
13127
  };
12421
13128
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
12422
13129
  function watchSource(projectDir, onChange, debounceMs = 200) {
12423
- const dir = existsSync15(join23(projectDir, "src")) ? join23(projectDir, "src") : projectDir;
13130
+ const dir = existsSync16(join24(projectDir, "src")) ? join24(projectDir, "src") : projectDir;
12424
13131
  let timer;
12425
13132
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
12426
13133
  const name = typeof file === "string" ? file : "";
@@ -12722,8 +13429,8 @@ async function emulatorDown(projectDir) {
12722
13429
  }
12723
13430
 
12724
13431
  // src/demos.ts
12725
- import { readFileSync as readFileSync18, existsSync as existsSync16, cpSync, mkdirSync as mkdirSync6 } from "node:fs";
12726
- import { dirname as dirname6, isAbsolute as isAbsolute4, join as join24, relative as relative7, resolve as resolve12, sep as sep6 } from "node:path";
13432
+ import { readFileSync as readFileSync18, existsSync as existsSync17, cpSync, mkdirSync as mkdirSync6 } from "node:fs";
13433
+ import { dirname as dirname6, isAbsolute as isAbsolute4, join as join25, relative as relative7, resolve as resolve15, sep as sep6 } from "node:path";
12727
13434
  import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
12728
13435
  function readCatalog(file, catalog) {
12729
13436
  let raw;
@@ -12734,7 +13441,7 @@ function readCatalog(file, catalog) {
12734
13441
  }
12735
13442
  const list3 = raw?.demos;
12736
13443
  if (!Array.isArray(list3)) return [];
12737
- const catalogDir = dirname6(resolve12(file));
13444
+ const catalogDir = dirname6(resolve15(file));
12738
13445
  return list3.filter((e) => validEntry(e)).map((e) => ({ ...e, catalog, catalogDir }));
12739
13446
  }
12740
13447
  function validEntry(e) {
@@ -12768,10 +13475,10 @@ function validEntry(e) {
12768
13475
  return true;
12769
13476
  }
12770
13477
  function loadDemoRegistry(pkgRoot3) {
12771
- const merged = readCatalog(join24(pkgRoot3, "demos.json"), "demos");
13478
+ const merged = readCatalog(join25(pkgRoot3, "demos.json"), "demos");
12772
13479
  const seen = new Set(merged.map((e) => e.name));
12773
13480
  const named = process.env.BEHOLD_WORKBENCH;
12774
- for (const e of [...readCatalog(join24(pkgRoot3, "workbench.json"), "workbench"), ...named ? readCatalog(resolve12(named), "workbench") : []]) {
13481
+ for (const e of [...readCatalog(join25(pkgRoot3, "workbench.json"), "workbench"), ...named ? readCatalog(resolve15(named), "workbench") : []]) {
12775
13482
  if (seen.has(e.name)) {
12776
13483
  process.stderr.write(`behold: workbench entry "${e.name}" collides with a demo already in the catalog \u2014 dropped
12777
13484
  `);
@@ -12784,17 +13491,17 @@ function loadDemoRegistry(pkgRoot3) {
12784
13491
  }
12785
13492
  function demoLocalPath(entry) {
12786
13493
  if (entry.source !== "local" || !entry.path) return void 0;
12787
- return isAbsolute4(entry.path) ? entry.path : resolve12(entry.catalogDir ?? process.cwd(), entry.path);
13494
+ return isAbsolute4(entry.path) ? entry.path : resolve15(entry.catalogDir ?? process.cwd(), entry.path);
12788
13495
  }
12789
13496
  function missingRequirements(entry) {
12790
13497
  const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
12791
13498
  const finder = process.platform === "win32" ? "where" : "which";
12792
13499
  const missing = bins.filter((bin) => {
12793
- if (bin === "choudoufu" && process.env.CHOUDOUFU_BIN && existsSync16(process.env.CHOUDOUFU_BIN)) return false;
13500
+ if (bin === "choudoufu" && process.env.CHOUDOUFU_BIN && existsSync17(process.env.CHOUDOUFU_BIN)) return false;
12794
13501
  return spawnSync2(finder, [bin], { stdio: "ignore" }).status !== 0;
12795
13502
  });
12796
13503
  const local = demoLocalPath(entry);
12797
- if (local && !existsSync16(local)) missing.push(`${entry.path} (not checked out)`);
13504
+ if (local && !existsSync17(local)) missing.push(`${entry.path} (not checked out)`);
12798
13505
  return missing;
12799
13506
  }
12800
13507
  function fetchesFromNetwork(entry) {
@@ -12803,9 +13510,9 @@ function fetchesFromNetwork(entry) {
12803
13510
  function demoTargetDir(entry, cwd = process.cwd()) {
12804
13511
  const inPlace = entry.inPlace ? demoLocalPath(entry) : void 0;
12805
13512
  if (inPlace) return inPlace;
12806
- const legacy = resolve12(cwd, "behold-demo");
12807
- if (entry.name === "writes" && existsSync16(legacy)) return legacy;
12808
- return resolve12(cwd, "behold-demos", entry.name);
13513
+ const legacy = resolve15(cwd, "behold-demo");
13514
+ if (entry.name === "writes" && existsSync17(legacy)) return legacy;
13515
+ return resolve15(cwd, "behold-demos", entry.name);
12809
13516
  }
12810
13517
  async function loadDemo(entry, opts) {
12811
13518
  const { pkgRoot: pkgRoot3, target } = opts;
@@ -12814,12 +13521,12 @@ async function loadDemo(entry, opts) {
12814
13521
  const inPlace = entry.source === "local" && entry.inPlace;
12815
13522
  const work = inPlace ? local : target;
12816
13523
  if (inPlace) {
12817
- if (!existsSync16(work)) return { ok: false, error: `${entry.path} is not checked out (looked in ${work})` };
13524
+ if (!existsSync17(work)) return { ok: false, error: `${entry.path} is not checked out (looked in ${work})` };
12818
13525
  say(`serving ${work} where it is \u2014 nothing copied`);
12819
- } else if (!existsSync16(target)) {
13526
+ } else if (!existsSync17(target)) {
12820
13527
  if (entry.source === "bundled" || entry.source === "local" && local) {
12821
- const from = entry.source === "bundled" ? join24(pkgRoot3, entry.dir) : local;
12822
- if (!existsSync16(from)) return { ok: false, error: entry.source === "bundled" ? `this install has no bundled ${entry.dir}` : `${entry.path} is not checked out (looked in ${from})` };
13528
+ const from = entry.source === "bundled" ? join25(pkgRoot3, entry.dir) : local;
13529
+ if (!existsSync17(from)) return { ok: false, error: entry.source === "bundled" ? `this install has no bundled ${entry.dir}` : `${entry.path} is not checked out (looked in ${from})` };
12823
13530
  say(`copying to ${target} (it's yours \u2014 edit it)`);
12824
13531
  try {
12825
13532
  cpSync(from, target, {
@@ -12845,7 +13552,7 @@ async function loadDemo(entry, opts) {
12845
13552
  } else {
12846
13553
  say(`reusing ${target}`);
12847
13554
  }
12848
- if (!inPlace && existsSync16(join24(work, "package.json")) && !existsSync16(join24(work, "node_modules"))) {
13555
+ if (!inPlace && existsSync17(join25(work, "package.json")) && !existsSync17(join25(work, "node_modules"))) {
12849
13556
  say("npm install\u2026");
12850
13557
  if (await runStep("npm", ["install"], { cwd: work, shell: process.platform === "win32" }) !== 0) {
12851
13558
  return { ok: false, error: `npm install failed in ${work}` };
@@ -12862,7 +13569,7 @@ async function loadDemo(entry, opts) {
12862
13569
  return { ok: false, error: `setup failed (${entry.setup})` };
12863
13570
  }
12864
13571
  }
12865
- return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join24(work, d)) : [work] };
13572
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join25(work, d)) : [work] };
12866
13573
  }
12867
13574
  function runStep(cmd, args, opts = {}) {
12868
13575
  return new Promise((res) => {
@@ -12873,8 +13580,8 @@ function runStep(cmd, args, opts = {}) {
12873
13580
  }
12874
13581
 
12875
13582
  // src/server.ts
12876
- var pkgRoot = join25(dirname7(fileURLToPath4(import.meta.url)), "..");
12877
- var webRoot = join25(pkgRoot, "web");
13583
+ var pkgRoot = join26(dirname7(fileURLToPath4(import.meta.url)), "..");
13584
+ var webRoot = join26(pkgRoot, "web");
12878
13585
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
12879
13586
  function optsFromQuery(url, tierEnvVar, projectDir) {
12880
13587
  const q = url.searchParams;
@@ -12925,7 +13632,7 @@ function tierFailure(tier, message) {
12925
13632
  function beholdVersion() {
12926
13633
  try {
12927
13634
  const here = dirname7(fileURLToPath4(import.meta.url));
12928
- return JSON.parse(readFileSync19(join25(here, "..", "package.json"), "utf8")).version ?? "unknown";
13635
+ return JSON.parse(readFileSync19(join26(here, "..", "package.json"), "utf8")).version ?? "unknown";
12929
13636
  } catch {
12930
13637
  return "unknown";
12931
13638
  }
@@ -13214,13 +13921,29 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13214
13921
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
13215
13922
  })) {
13216
13923
  const app = new Hono();
13924
+ const guard = { allowedHosts: allowedHostsFrom(cfg.allowedHosts ?? []), ...cfg.host ? { boundHost: cfg.host } : {} };
13925
+ app.use("*", async (c, next) => {
13926
+ const verdict = guardRequest(
13927
+ {
13928
+ method: c.req.method,
13929
+ host: c.req.header("host") ?? new URL(c.req.url).host,
13930
+ origin: c.req.header("origin") ?? null,
13931
+ secFetchSite: c.req.header("sec-fetch-site") ?? null
13932
+ },
13933
+ guard
13934
+ );
13935
+ if (!verdict.ok) return c.json({ error: verdict.error, code: "request-refused" }, verdict.status);
13936
+ await next();
13937
+ });
13217
13938
  setChoudoufuRunner(cfg.choudoufu?.run);
13218
13939
  setEstateLexiconRead(cfg.choudoufu ? cfg.choudoufu.lexicon ?? null : void 0);
13940
+ setServedWorkspace(cfg.workspace);
13219
13941
  app.use("/api/*", async (c, next) => {
13220
13942
  if (c.req.method === "GET") return withReadSignal(c.req.raw.signal, next);
13221
13943
  await next();
13222
13944
  });
13223
13945
  if (cfg.carveReport) carveRoutes(app, cfg.carveReport, cfg.carveDemo);
13946
+ const predictDeps = cfg.behaviour ?? { read: graphIr };
13224
13947
  let beholdConfig = loadBeholdConfig(cfg.projectDir);
13225
13948
  let tierEnvVar = beholdConfig.tiers?.envVar;
13226
13949
  const executorFor = (env) => env ? beholdConfig.executor?.[env] : void 0;
@@ -13331,9 +14054,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13331
14054
  return c.json({ started: true, name, ran: label });
13332
14055
  });
13333
14056
  app.post("/api/local/reset", (c) => {
13334
- const down = join25(cfg.projectDir, "scripts/local/local-down.sh");
13335
- const up = join25(cfg.projectDir, "scripts/local/local-up.sh");
13336
- if (!existsSync17(down) || !existsSync17(up)) {
14057
+ const down = join26(cfg.projectDir, "scripts/local/local-down.sh");
14058
+ const up = join26(cfg.projectDir, "scripts/local/local-up.sh");
14059
+ if (!existsSync18(down) || !existsSync18(up)) {
13337
14060
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
13338
14061
  }
13339
14062
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -13433,6 +14156,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13433
14156
  return c.json({ history });
13434
14157
  });
13435
14158
  app.post("/api/operator/approve/:op/:gate", async (c) => {
14159
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
13436
14160
  const { op, gate } = c.req.param();
13437
14161
  const info = estateOps().find((o) => o.name === op);
13438
14162
  broadcaster.emit("op", `\u270E approve ${op} ${gate} \u2014 records a fact; the next tick acts on it`);
@@ -13443,6 +14167,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13443
14167
  return c.json({ recorded: true, op, gate, semantics: APPROVE_SEMANTICS });
13444
14168
  });
13445
14169
  app.post("/api/ops/:name/signal/:gate", async (c) => {
14170
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
13446
14171
  const { name, gate } = c.req.param();
13447
14172
  const info = estateOps().find((o) => o.name === name);
13448
14173
  broadcaster.emit("op", `\u270E signal ${name} ${gate}`);
@@ -13450,6 +14175,38 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13450
14175
  if (code !== 0) return c.json({ error: stderr.trim() || `signal exited ${code}` }, 500);
13451
14176
  return c.json({ signalled: true });
13452
14177
  });
14178
+ app.get("/api/workspace/gates", async (c) => {
14179
+ const ws = servedWorkspace();
14180
+ const env = c.req.query("env") || cfg.env || "local";
14181
+ if (!isEnvName(env)) return c.json({ error: `${JSON.stringify(env)} isn't an env name`, code: "env" }, 400);
14182
+ if (!ws) return c.json({ workspace: false, env, gates: [], approver: localApprover() });
14183
+ const read = await readWorkspaceGates(ws, env);
14184
+ if (!read.ok) return c.json({ workspace: true, env, gates: [], refusal: read.refusal, approver: localApprover() });
14185
+ return c.json({ workspace: true, env, gates: read.gates.map(({ dir: _dir, ...g }) => g), approver: localApprover() });
14186
+ });
14187
+ app.post("/api/workspace/gates/approve", async (c) => {
14188
+ if (cfg.previewMode) return c.json({ error: "approving is disabled in preview mode", code: "preview" }, 403);
14189
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) {
14190
+ return c.json({ error: "send the body as application/json", code: "content-type" }, 415);
14191
+ }
14192
+ const ws = servedWorkspace();
14193
+ if (!ws) return c.json({ error: "behold isn't serving a declared workspace", code: "no-workspace" }, 404);
14194
+ const body = await c.req.json().catch(() => ({}));
14195
+ const key = typeof body?.key === "string" ? body.key : "";
14196
+ const env = typeof body?.env === "string" && body.env ? body.env : cfg.env || "local";
14197
+ if (!isEnvName(env)) return c.json({ error: `${JSON.stringify(env)} isn't an env name`, code: "env" }, 400);
14198
+ const read = await readWorkspaceGates(ws, env);
14199
+ if (!read.ok) return c.json({ error: read.refusal.error, code: read.refusal.code }, 422);
14200
+ const g = read.gates.find((x) => x.key === key);
14201
+ if (!g) return c.json({ error: `no gate ${key} is waiting in ${env}`, code: "no-gate" }, 404);
14202
+ if (g.signed) return c.json({ error: `${key} wants a signed approval, which behold can't make. Run: ${g.approve}`, code: "signed" }, 422);
14203
+ const approver = localApprover();
14204
+ broadcaster.emit("op", `\u270E approve ${g.key} as ${approver}`);
14205
+ const { code, stderr, stdout } = await runChantRaw(approveArgs(g), g.dir);
14206
+ if (code !== 0) return c.json({ error: stderr.trim() || stdout.trim() || `approve exited ${code}`, code: "approve" }, 500);
14207
+ broadcaster.emit("changed", "");
14208
+ return c.json({ recorded: true, key: g.key, approver });
14209
+ });
13453
14210
  app.get(
13454
14211
  "/api/events",
13455
14212
  (c) => streamSSE(c, async (stream) => {
@@ -13516,6 +14273,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13516
14273
  currentEnv: cfg.env ?? null,
13517
14274
  // v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
13518
14275
  ...cfg.previewMode ? { previewMode: true } : {},
14276
+ // #477: who `chant approve` records when behold runs it. The approve
14277
+ // buttons say it before the click.
14278
+ approver: localApprover(),
13519
14279
  // The tier picker's options (M2 #54, sourced #70): gated on the served
13520
14280
  // project's `.behold.json` declaring a `tiers` block at all — NOT on
13521
14281
  // whether its env var happens to be set in behold's own launch env
@@ -13566,9 +14326,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13566
14326
  app.post("/api/project/open", async (c) => {
13567
14327
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
13568
14328
  const body = await c.req.json().catch(() => ({}));
13569
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve13(body.dir.trim()) : "";
13570
- if (!dir || !existsSync17(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
13571
- if (!existsSync17(join25(dir, "chant.config.ts"))) {
14329
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve16(body.dir.trim()) : "";
14330
+ if (!dir || !existsSync18(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
14331
+ if (!existsSync18(join26(dir, "chant.config.ts"))) {
13572
14332
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
13573
14333
  }
13574
14334
  switchServedProject([dir]);
@@ -13577,6 +14337,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13577
14337
  const switchServedProject = (dirs, env, spawnEnv) => {
13578
14338
  addRecent(cfg.projectDir);
13579
14339
  cfg.projectDir = dirs[0];
14340
+ cfg.workspace = void 0;
14341
+ setServedWorkspace(void 0);
13580
14342
  cfg.projectDirs = servesAsEstate(dirs) ? dirs : void 0;
13581
14343
  setChoudoufuSpawnEnv(spawnEnv);
13582
14344
  cfg.env = env;
@@ -13589,7 +14351,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13589
14351
  };
13590
14352
  app.post("/api/project/reveal", async (c) => {
13591
14353
  const body = await c.req.json().catch(() => ({}));
13592
- const dir = typeof body.dir === "string" && body.dir.trim() ? resolve13(body.dir.trim()) : cfg.projectDir;
14354
+ const dir = typeof body.dir === "string" && body.dir.trim() ? resolve16(body.dir.trim()) : cfg.projectDir;
13593
14355
  const known = /* @__PURE__ */ new Set([cfg.projectDir, ...cfg.projectDirs ?? [], ...listRecents().map((r) => r.dir)]);
13594
14356
  if (!known.has(dir)) return c.json({ error: "not a served or recent project directory" }, 400);
13595
14357
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
@@ -13617,7 +14379,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13617
14379
  fetches: fetchesFromNetwork(e),
13618
14380
  ...e.repo ? { repo: e.repo } : {},
13619
14381
  target,
13620
- loaded: existsSync17(target),
14382
+ loaded: existsSync18(target),
13621
14383
  satisfiable: missing.length === 0,
13622
14384
  // #254: the carve walkthrough runs fine here — it just isn't a project
13623
14385
  // to switch INTO. Carve mode claims `/api/graph` and `/api/project` at
@@ -13676,20 +14438,21 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13676
14438
  loadingDemo = null;
13677
14439
  }
13678
14440
  });
14441
+ const layoutDir = () => cfg.workspace?.root ?? cfg.projectDir;
13679
14442
  const layoutWriteBlock = () => {
13680
14443
  if (cfg.previewMode) return "the layout sidecar is read-only in preview mode";
13681
14444
  if (cfg.carveReport) return "a carve report isn't a project \u2014 there's nowhere to keep a hand layout";
13682
14445
  if (cfg.layoutWrites === false) return "a static export captures a snapshot \u2014 it doesn't write to the project";
13683
- return unwritableReason(cfg.projectDir);
14446
+ return unwritableReason(layoutDir());
13684
14447
  };
13685
14448
  app.get("/api/layout", (c) => {
13686
14449
  const block = layoutWriteBlock();
13687
- const shared2 = { path: layoutPath(cfg.projectDir), writable: !block, ...block ? { reason: block } : {} };
14450
+ const shared2 = { path: layoutPath(layoutDir()), writable: !block, ...block ? { reason: block } : {} };
13688
14451
  const raw = new URL(c.req.url).searchParams.get("lens");
13689
- if (raw === null) return c.json({ ...shared2, lenses: readLayoutFile(cfg.projectDir).lenses });
14452
+ if (raw === null) return c.json({ ...shared2, lenses: readLayoutFile(layoutDir()).lenses });
13690
14453
  const lens = normalizeLens(raw);
13691
14454
  if (!lens) return c.json({ error: `not a lens key: ${raw}`, code: "bad-layout" }, 400);
13692
- return c.json({ ...shared2, lens, deltas: readLens(cfg.projectDir, lens) });
14455
+ return c.json({ ...shared2, lens, deltas: readLens(layoutDir(), lens) });
13693
14456
  });
13694
14457
  app.post("/api/layout", async (c) => {
13695
14458
  const block = layoutWriteBlock();
@@ -13713,8 +14476,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13713
14476
  return c.json({ error: "body needs `deltas`: {<node id>: {dx,dy,dw,dh}}", code: "bad-layout" }, 400);
13714
14477
  }
13715
14478
  try {
13716
- const stored = writeLens(cfg.projectDir, lens, body.deltas);
13717
- return c.json({ ok: true, lens: stored.lens, deltas: stored.deltas, count: Object.keys(stored.deltas).length, path: layoutPath(cfg.projectDir) });
14479
+ const stored = writeLens(layoutDir(), lens, body.deltas);
14480
+ return c.json({ ok: true, lens: stored.lens, deltas: stored.deltas, count: Object.keys(stored.deltas).length, path: layoutPath(layoutDir()) });
13718
14481
  } catch (err) {
13719
14482
  if (err instanceof LayoutTooLarge) return c.json({ error: err.message, code: "too-large" }, 413);
13720
14483
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
@@ -13727,7 +14490,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13727
14490
  const res = c.res;
13728
14491
  if (!res || res.status !== 200 || !(res.headers.get("content-type") ?? "").includes("json")) return;
13729
14492
  const lens = lensFromQuery(url.searchParams);
13730
- const deltas = readLens(cfg.projectDir, lens);
14493
+ const deltas = readLens(layoutDir(), lens);
13731
14494
  if (!Object.keys(deltas).length) return;
13732
14495
  let body;
13733
14496
  try {
@@ -13776,7 +14539,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13776
14539
  {
13777
14540
  method: "GET",
13778
14541
  path: "/api/overlay",
13779
- desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1; each priced entity carries attrs._behaviour (cost/headroom/errorRate/resilience/rightSize/provenance, #398) and meta.behaviour holds the engine, its sums, a refusal or an absent line"
14542
+ desc: "live drift overlay for ?env= \u2014 same shape/params as /api/graph, plus runtime=1; each priced entity carries attrs._behaviour (cost/headroom/errorRate/resilience/rightSize/provenance, #398) and meta.behaviour holds the engine, its sums, a refusal or an absent line; with ?traffic= (or .behold.json behaviour.traffic) each member is predicted live and declared, and meta.behaviour adds live, declared and delta with attrs._behaviourDeclared per entity (#402)"
13780
14543
  },
13781
14544
  { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens=<key> \u2192 {lens, deltas, writable}; no lens \u2192 every lens" },
13782
14545
  { method: "POST", path: "/api/layout", desc: "store one lens's deltas: JSON body {lens, deltas: {<node id>: {dx,dy,dw,dh}}} (the only file behold writes in your project)" },
@@ -13799,10 +14562,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13799
14562
  { method: "GET", path: "/api/history", desc: "recent source commits (rollback targets)" },
13800
14563
  { method: "GET", path: "/api/frames", desc: "captured lanes frames" },
13801
14564
  { method: "GET", path: "/api/events", desc: "SSE: changed / op / apply / run / pr" },
13802
- { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph" },
14565
+ { method: "POST", path: "/api/refresh", desc: "re-observe live now (?env=) \u2014 returns the fresh graph. With ?notify=1 it is a host's notice that the workspace changed: cached reads are dropped and every open page re-pulls (#476)" },
13803
14566
  { method: "POST", path: "/api/apply", desc: "delegated apply: ?env=&component=<name|all> (guarded, preview-locked)" },
13804
14567
  { method: "POST", path: "/api/ops/:name/run", desc: "run a committed Op (delegated write)" },
13805
14568
  { method: "POST", path: "/api/ops/:name/signal/:gate", desc: "approve an Op's gate (releases the waiting run)" },
14569
+ { method: "GET", path: "/api/workspace/gates", desc: "a declared workspace's waiting gates from chant workspace status <env> --json (?env=), keyed member/op/gate, with the approver behold would record (#477)" },
14570
+ { method: "POST", path: "/api/workspace/gates/approve", desc: "approve one workspace gate: JSON body {key, env}; env and plan come from a fresh status read; recorded as the user running behold; preview-locked (#477)" },
13806
14571
  { method: "POST", path: "/api/operator/approve/:op/:gate", desc: "record a converge gate's resolution (chant approve \u2014 a fact for the next tick, not an unblock)" },
13807
14572
  { method: "POST", path: "/api/rollback", desc: "open a rollback PR: ?to=<sha>" },
13808
14573
  { method: "POST", path: "/api/substrates/:name/up", desc: "bring a substrate up" },
@@ -13878,8 +14643,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13878
14643
  }
13879
14644
  const runState = runner.runState;
13880
14645
  const ranOp = runState.op ? parsedOps.find((o) => o.name === runState.op) : void 0;
13881
- const join28 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
13882
- const opsIr = join28 ? paintPlayhead(declaredIr, join28, runState.gate) : declaredIr;
14646
+ const join30 = ranOp ? joinRun(ranOp, runState.records, runState.gate) : null;
14647
+ const opsIr = join30 ? paintPlayhead(declaredIr, join30, runState.gate) : declaredIr;
13883
14648
  const { svg: svg2 } = renderGraph(opsIr, { boxes: "byStack" });
13884
14649
  const operator = runner.noteDeclaredConvergeOps(declaredConvergeOps(parsedOps));
13885
14650
  return c.json({
@@ -13892,7 +14657,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13892
14657
  target: null,
13893
14658
  mode: "ops",
13894
14659
  ops: parsedOps.length,
13895
- note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join28)} ${operatorNote(operator)}`,
14660
+ note: `${opsNote(parsedOps, opsIr)} ${playheadNote(runState, join30)} ${operatorNote(operator)}`,
13896
14661
  // The operator strip's own model: the ConvergeOps this project
13897
14662
  // declares, plus whatever the last status read said. `declared`
13898
14663
  // without a `strip` is the honest starting state — a loop exists and
@@ -13999,12 +14764,21 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
13999
14764
  srcCompositeEdgesAttached = 0;
14000
14765
  }
14001
14766
  }
14002
- if (!multi && !components && !logical && !opts.lens && !ownKindVia && ir.nodes.length === 0 && !existsSync17(join25(cfg.projectDir, "chant.config.ts"))) {
14767
+ if (!multi && !components && !logical && !opts.lens && !ownKindVia && ir.nodes.length === 0 && !existsSync18(join26(cfg.projectDir, "chant.config.ts"))) {
14003
14768
  return c.json(noProjectError(cfg.projectDir), 404);
14004
14769
  }
14005
14770
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
14006
14771
  const collapse = new URL(c.req.url).searchParams.get("collapse") === "1";
14007
14772
  let collapsedNote;
14773
+ const srcTraffic = components ? void 0 : trafficFor(new URL(c.req.url).searchParams, beholdConfig);
14774
+ const srcBehaviour = srcTraffic ? attachDeclaredBehaviour(
14775
+ ir,
14776
+ await Promise.all(
14777
+ (multi ? estateMembers(cfg.projectDirs) : [{ name: void 0, dir: cfg.projectDir, kind: memberKindOf(cfg.projectDir) ?? "chant" }]).map(
14778
+ async (m) => ({ name: m.name, prediction: await predictMember(m, void 0, srcTraffic, predictDeps) })
14779
+ )
14780
+ )
14781
+ ) : void 0;
14008
14782
  const expanded = ir;
14009
14783
  if (multi && collapse) {
14010
14784
  const shut = collapseBoxes(ir);
@@ -14029,6 +14803,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14029
14803
  meta: {
14030
14804
  projectDir: cfg.projectDir,
14031
14805
  env: metaEnv,
14806
+ ...srcBehaviour ? { behaviour: srcBehaviour } : {},
14032
14807
  ...srcNote ? { note: srcNote } : {},
14033
14808
  // #393 item 7: the strip's form of the same note, when there is a
14034
14809
  // shorter true one. Absent means "the note fits" — the SPA shows
@@ -14162,7 +14937,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14162
14937
  });
14163
14938
  const choudoufuPlanDrift = async (dirs, wanted) => {
14164
14939
  const members = estateMembers(dirs).filter((m) => m.kind === "choudoufu");
14165
- const byDir = /* @__PURE__ */ new Map();
14940
+ const byDir2 = /* @__PURE__ */ new Map();
14166
14941
  let read = false;
14167
14942
  for (const m of members) {
14168
14943
  const stamp = memberSourceStamp(m.dir);
@@ -14170,7 +14945,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14170
14945
  const hit = cachedChoudoufuPlan(m.dir, stamp);
14171
14946
  if (hit) {
14172
14947
  read = true;
14173
- byDir.set(m.dir, { name: m.name, drift: hit });
14948
+ byDir2.set(m.dir, { name: m.name, drift: hit });
14174
14949
  }
14175
14950
  continue;
14176
14951
  }
@@ -14178,11 +14953,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14178
14953
  const drift = await readChoudoufuPlan(m.dir, cfg.choudoufu?.run);
14179
14954
  cacheChoudoufuPlan(m.dir, stamp, drift);
14180
14955
  read = true;
14181
- byDir.set(m.dir, { name: m.name, drift });
14956
+ byDir2.set(m.dir, { name: m.name, drift });
14182
14957
  } catch {
14183
14958
  }
14184
14959
  }
14185
- return { read, byDir };
14960
+ return { read, byDir: byDir2 };
14186
14961
  };
14187
14962
  app.get("/api/overlay", async (c) => {
14188
14963
  const query = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir);
@@ -14224,11 +14999,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14224
14999
  mode: "progressive",
14225
15000
  // Which members the picture is still waiting on, so a client can
14226
15001
  // say so without inferring it from the attrs.
14227
- pending: shortStackNames2(cfg.projectDirs)
15002
+ pending: estateMemberNames(cfg.projectDirs)
14228
15003
  }
14229
15004
  });
14230
15005
  }
14231
- const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail, env }, reclassifyOverlay, { fresh: planWanted });
15006
+ const traffic2 = trafficFor(new URL(c.req.url).searchParams, beholdConfig);
15007
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail, env, ...traffic2 ? { traffic: traffic2 } : {} }, reclassifyOverlay, { fresh: planWanted });
14232
15008
  if (est.dropped.length === est.total) {
14233
15009
  return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
14234
15010
  }
@@ -14267,11 +15043,24 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14267
15043
  meta: { projectDir: cfg.projectDir, env, mode: "logical", estate: est.total, vocabulary, ...note3 ? { note: note3 } : {} }
14268
15044
  });
14269
15045
  }
14270
- const behaviour2 = attachBehaviour(
15046
+ const predicted = traffic2 ? await Promise.all(
15047
+ estateMembers(cfg.projectDirs).map(async (m) => ({ m, prediction: await predictMember(m, env, traffic2, predictDeps, planWanted) }))
15048
+ ) : [];
15049
+ const paintNotes2 = [];
15050
+ for (const { m, prediction: prediction2 } of predicted) {
15051
+ if (prediction2.live) est.memberMeta[m.name] = paintLive(ir2, m.name, prediction2.live, paintNotes2);
15052
+ }
15053
+ const attached2 = attachBehaviour(
14271
15054
  ir2,
14272
15055
  estateMembers(cfg.projectDirs).map((m) => ({ name: m.name, dir: m.dir, meta: est.memberMeta[m.name] })),
14273
- env
15056
+ env,
15057
+ ...traffic2 ? [() => void 0] : []
14274
15058
  );
15059
+ const behaviour2 = traffic2 ? attachBehaviourDelta(
15060
+ ir2,
15061
+ predicted.map(({ m, prediction: prediction2 }) => ({ name: m.name, liveMeta: est.memberMeta[m.name], prediction: prediction2 })),
15062
+ paintNotes2.length ? { ...attached2, diagnostics: [...attached2.diagnostics ?? [], ...paintNotes2] } : attached2
15063
+ ) : attached2;
14275
15064
  const collapsed = new URL(c.req.url).searchParams.get("collapse") === "1" && !runtime ? collapseBoxes(ir2) : void 0;
14276
15065
  const expanded = ir2;
14277
15066
  if (collapsed) ir2 = collapsed.ir;
@@ -14317,7 +15106,16 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14317
15106
  }
14318
15107
  });
14319
15108
  }
14320
- const opts = { ...query, live: true, overlay: true, env, ...logical || runtime ? { detail: 3 } : {} };
15109
+ const traffic = trafficFor(new URL(c.req.url).searchParams, beholdConfig);
15110
+ const ownKind = memberKindOf(cfg.projectDir) ?? "chant";
15111
+ const opts = {
15112
+ ...query,
15113
+ live: true,
15114
+ overlay: true,
15115
+ env,
15116
+ ...logical || runtime ? { detail: 3 } : {},
15117
+ ...traffic && memberTakesTraffic(cfg.projectDir, ownKind) ? { traffic } : {}
15118
+ };
14321
15119
  const read = await graphIr(cfg.projectDir, opts);
14322
15120
  const readMeta = read.meta;
14323
15121
  let ir = joinCarvedSources(reclassifyOverlay(read), (await carveStatesFor(cfg.projectDir)).values(), cfg.projectDir).ir;
@@ -14358,7 +15156,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14358
15156
  compositeEdgesAttached = 0;
14359
15157
  }
14360
15158
  }
14361
- const behaviour = attachBehaviour(ir, [{ dir: cfg.projectDir, meta: readMeta }], env);
15159
+ const prediction = traffic ? await predictMember({ dir: cfg.projectDir, kind: ownKind }, env, traffic, predictDeps) : void 0;
15160
+ const paintNotes = [];
15161
+ const liveMeta = prediction?.live ? paintLive(ir, void 0, prediction.live, paintNotes) : readMeta;
15162
+ const attached = attachBehaviour(ir, [{ dir: cfg.projectDir, meta: liveMeta }], env, ...traffic ? [() => void 0] : []);
15163
+ const behaviour = prediction ? attachBehaviourDelta(ir, [{ liveMeta, prediction }], paintNotes.length ? { ...attached, diagnostics: [...attached.diagnostics ?? [], ...paintNotes] } : attached) : attached;
14362
15164
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
14363
15165
  const zoom = runtime ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
14364
15166
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
@@ -14371,7 +15173,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14371
15173
  }
14372
15174
  });
14373
15175
  app.post("/api/refresh", async (c) => {
14374
- const env = optsFromQuery(new URL(c.req.url)).env ?? cfg.env;
15176
+ const url = new URL(c.req.url);
15177
+ if (url.searchParams.get("notify") === "1") {
15178
+ invalidateMember();
15179
+ invalidateOverlay();
15180
+ broadcaster.emit("changed", "");
15181
+ return c.json({ notified: true });
15182
+ }
15183
+ const env = optsFromQuery(url).env ?? cfg.env;
14375
15184
  const result = await captureFrame(cfg.projectDir, env, frames, broadcaster);
14376
15185
  if (!result) return c.json({ error: "refresh failed \u2014 see server log" }, 500);
14377
15186
  if (env) reclassifyOverlay(result.ir);
@@ -14451,9 +15260,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14451
15260
  });
14452
15261
  const choudoufuPlanPath = (plan) => {
14453
15262
  if (!plan) return { error: "no plan named", code: "choudoufu_no_plan", remedy: "pass ?plan=<path to a carve.json inside a served choudoufu member> \u2014 GET /api/project lists the ones found" };
14454
- const abs = resolve13(cfg.projectDir, plan);
15263
+ const abs = resolve16(cfg.projectDir, plan);
14455
15264
  const members = (cfg.projectDirs ?? [cfg.projectDir]).filter((d) => memberKindOf(d) === "choudoufu");
14456
- const inside = members.some((d) => abs.startsWith(resolve13(d) + sep7));
15265
+ const inside = members.some((d) => abs.startsWith(resolve16(d) + sep7));
14457
15266
  if (!inside) return { error: `${plan} is not inside a served choudoufu member`, code: "choudoufu_plan_outside", remedy: "a plan lives in the estate it moves; point ?plan= at a carve.json under one of the served members" };
14458
15267
  return { path: abs };
14459
15268
  };
@@ -14584,8 +15393,12 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
14584
15393
  return c.json({ started: true, component, env });
14585
15394
  });
14586
15395
  const rel = relative8(process.cwd(), webRoot) || ".";
15396
+ app.use("/*", async (c, next) => {
15397
+ await next();
15398
+ if (!c.req.path.startsWith("/api/") && c.res.ok && !c.res.headers.has("cache-control")) c.res.headers.set("cache-control", "no-cache");
15399
+ });
14587
15400
  app.use("/*", serveStatic({ root: rel }));
14588
- app.get("/", serveStatic({ path: join25(rel, "index.html") }));
15401
+ app.get("/", serveStatic({ path: join26(rel, "index.html") }));
14589
15402
  return app;
14590
15403
  }
14591
15404
  async function bootLocalEmulators(dir, who) {
@@ -14614,7 +15427,7 @@ async function bootLocalEmulators(dir, who) {
14614
15427
  async function startServer(cfg) {
14615
15428
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
14616
15429
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
14617
- if (existsSync17(join25(dir, "chant.config.ts"))) addRecent(dir);
15430
+ if (existsSync18(join26(dir, "chant.config.ts"))) addRecent(dir);
14618
15431
  }
14619
15432
  const broadcaster = new Broadcaster();
14620
15433
  const frames = new FrameBuffer();
@@ -14623,6 +15436,7 @@ async function startServer(cfg) {
14623
15436
  broadcaster,
14624
15437
  onDone: (opEnv) => captureFrame(cfg.projectDir, opEnv ?? cfg.env, frames, broadcaster)
14625
15438
  });
15439
+ if (!cfg.host && process.env.BEHOLD_HOST) cfg.host = process.env.BEHOLD_HOST;
14626
15440
  const app = createApp(cfg, broadcaster, frames, runner);
14627
15441
  const autoSync = cfg.autoSync ?? "off";
14628
15442
  if (!cfg.carveReport) {
@@ -14646,7 +15460,7 @@ async function startServer(cfg) {
14646
15460
  invalidateOverlay(memberDir);
14647
15461
  onEstateChange(memberDir);
14648
15462
  };
14649
- const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename2(dir)}]` : "";
15463
+ const memberTag = (dir) => (cfg.projectDirs?.length ?? 0) > 1 ? ` [${basename3(dir)}]` : "";
14650
15464
  const onPollDrift = (dir, movedLexicons) => {
14651
15465
  broadcaster.emit("changed", dir);
14652
15466
  if (autoSync === "off") return;
@@ -14656,7 +15470,7 @@ async function startServer(cfg) {
14656
15470
  const suspended = autoSync === "pull-request" ? suspendedByRollback(await openRollbackBranches(dir, cfg.env), movedLexicons) : /* @__PURE__ */ new Set();
14657
15471
  const { routed, local } = splitForgeRouted(discoverEstateOps(cfg.projectDirs ?? [cfg.projectDir]), executorDesignation);
14658
15472
  for (const { op: o, designation: d } of routed) {
14659
- 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 ${basename2(o.dir)}/.behold.json \u2014 never applied from here`);
15473
+ 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 ${basename3(o.dir)}/.behold.json \u2014 never applied from here`);
14660
15474
  }
14661
15475
  const { picks, declined } = pickAutoSyncOps(
14662
15476
  autoSync,
@@ -14730,7 +15544,8 @@ async function startServer(cfg) {
14730
15544
  };
14731
15545
  process.on("SIGINT", shutdown);
14732
15546
  process.on("SIGTERM", shutdown);
14733
- const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => {
15547
+ const hostname = cfg.host ?? process.env.BEHOLD_HOST ?? "127.0.0.1";
15548
+ const server = serve({ fetch: app.fetch, port: cfg.port, hostname }, (info) => {
14734
15549
  if (carve) {
14735
15550
  process.stdout.write(
14736
15551
  `behold \u2192 http://localhost:${info.port}
@@ -14778,9 +15593,132 @@ async function startServer(cfg) {
14778
15593
  // src/cli.ts
14779
15594
  init_chant();
14780
15595
 
15596
+ // src/workspace-convert.ts
15597
+ init_chant();
15598
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readFileSync as readFileSync20, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "node:fs";
15599
+ import { basename as basename4, dirname as dirname8, join as join27, relative as relative9, resolve as resolve17, sep as sep8 } from "node:path";
15600
+ import { shortStackNames as shortStackNames2 } from "@intentius/pinhole";
15601
+ init_project();
15602
+ init_terraform_member();
15603
+ var TERRAFORM_LEXICON2 = "@intentius/chant-lexicon-terraform";
15604
+ function declarationName(raw) {
15605
+ const folded = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
15606
+ return folded || "member";
15607
+ }
15608
+ function unique(name, taken) {
15609
+ let out = name;
15610
+ for (let i = 2; taken.has(out); i++) out = `${name.slice(0, 40 - String(i).length - 1)}-${i}`;
15611
+ taken.add(out);
15612
+ return out;
15613
+ }
15614
+ var posix = (p) => p.split(sep8).join("/");
15615
+ function installedVersion(pkg, dir) {
15616
+ for (let at = resolve17(dir); ; at = dirname8(at)) {
15617
+ const manifest = join27(at, "node_modules", ...pkg.split("/"), "package.json");
15618
+ if (existsSync19(manifest)) {
15619
+ try {
15620
+ const v = JSON.parse(readFileSync20(manifest, "utf8")).version;
15621
+ return typeof v === "string" ? v : void 0;
15622
+ } catch {
15623
+ return void 0;
15624
+ }
15625
+ }
15626
+ if (dirname8(at) === at) return void 0;
15627
+ }
15628
+ }
15629
+ function planConversion(rootArg) {
15630
+ const root = resolve17(rootArg);
15631
+ const existing = DECLARATION_FILES.find((f) => existsSync19(join27(root, f)));
15632
+ if (existing) return { ok: false, error: `${root} already has ${existing}; there is nothing to convert.` };
15633
+ const shape = detectProjectShape(root);
15634
+ if (shape.kind !== "estate" || !shape.members?.length || shape.membersFrom === "itself") {
15635
+ return { ok: false, error: `${root} is not an estate root with a member list, so there is no member list to convert.` };
15636
+ }
15637
+ const abs = shape.members.map((m) => resolve17(root, m.dir));
15638
+ const shorts = shortStackNames2(abs);
15639
+ const taken = /* @__PURE__ */ new Set();
15640
+ const members = [];
15641
+ const moves = [];
15642
+ const notes = [];
15643
+ let needsLexicon = false;
15644
+ shape.members.forEach((m, i) => {
15645
+ const short = shorts[i];
15646
+ const dir = posix(relative9(root, abs[i])) || ".";
15647
+ if (m.kind === "terraform") {
15648
+ needsLexicon = true;
15649
+ const scan = discoverTerraformRoots(abs[i]);
15650
+ for (const r of scan.roots) {
15651
+ const name2 = unique(declarationName(r.name), taken);
15652
+ members.push({ name: name2, dir: r.dir === "." ? dir : posix(join27(dir, r.dir)), kind: "terraform" });
15653
+ moves.push({ from: `${short}/${r.name}/`, to: `${name2}/${name2}/` });
15654
+ }
15655
+ for (const s of scan.skipped) notes.push(`${posix(join27(dir, s.dir))} is not declared: ${s.why}.`);
15656
+ if (scan.roots.length > 1) notes.push(`${dir} held ${scan.roots.length} Terraform roots; each is its own member now, since a chant terraform member is one root.`);
15657
+ return;
15658
+ }
15659
+ if (m.kind === "choudoufu") needsLexicon = true;
15660
+ const name = unique(declarationName(short), taken);
15661
+ members.push({ name, dir, kind: m.kind });
15662
+ if (name !== short) {
15663
+ moves.push({ from: `${short}/`, to: `${name}/` });
15664
+ notes.push(`member ${short} is declared as ${name}: a declared name is lowercase letters, digits and hyphens.`);
15665
+ }
15666
+ });
15667
+ const pins = [];
15668
+ const chant = resolveChant(root);
15669
+ if (chant.source === "project" && chant.version) pins.push({ package: "@intentius/chant", version: chant.version });
15670
+ if (needsLexicon) {
15671
+ const version = installedVersion(TERRAFORM_LEXICON2, root);
15672
+ if (version) pins.push({ package: TERRAFORM_LEXICON2, version });
15673
+ else notes.push(`${TERRAFORM_LEXICON2} is not installed where chant looks from ${root}; install it there and add it to "pins", or chant cannot read the terraform and choudoufu members.`);
15674
+ }
15675
+ const declaration = {
15676
+ name: declarationName(basename4(root)),
15677
+ schema: 1,
15678
+ ...pins.length ? { pins } : {},
15679
+ members
15680
+ };
15681
+ const firstLayout = layoutPath(abs[0]);
15682
+ moves.sort((a, b) => b.from.length - a.from.length);
15683
+ return { ok: true, plan: { root, from: shape.membersFrom === "behold-config" ? "behold-config" : "workspaces", declaration, moves, ...existsSync19(firstLayout) ? { layoutFrom: firstLayout } : {}, notes } };
15684
+ }
15685
+ function moveLayoutIds(file, moves) {
15686
+ const lenses = {};
15687
+ for (const [lens, deltas] of Object.entries(file.lenses)) {
15688
+ const out = {};
15689
+ for (const [id, d] of Object.entries(deltas)) {
15690
+ const move = moves.find((m) => id.startsWith(m.from));
15691
+ out[move ? move.to + id.slice(move.from.length) : id] = d;
15692
+ }
15693
+ lenses[lens] = out;
15694
+ }
15695
+ return { version: 1, lenses };
15696
+ }
15697
+ function applyConversion(plan) {
15698
+ const declarationFile = join27(plan.root, DECLARATION_FILES[0]);
15699
+ if (existsSync19(declarationFile)) throw new Error(`${declarationFile} appeared while converting; nothing was written`);
15700
+ writeFileSync6(declarationFile, `${JSON.stringify(plan.declaration, null, 2)}
15701
+ `);
15702
+ let layout;
15703
+ const target = layoutPath(plan.root);
15704
+ if (!plan.layoutFrom) layout = { skipped: "no saved layout to move" };
15705
+ else if (resolve17(plan.layoutFrom) === resolve17(target)) layout = { skipped: "the saved layout is already in the root" };
15706
+ else if (existsSync19(target)) layout = { skipped: `${target} already exists; the layout in ${plan.layoutFrom} was left alone` };
15707
+ else {
15708
+ const moved = moveLayoutIds(readLayoutFile(dirname8(dirname8(plan.layoutFrom))), plan.moves);
15709
+ mkdirSync7(join27(plan.root, LAYOUT_DIR), { recursive: true });
15710
+ const tmp = `${target}.tmp`;
15711
+ writeFileSync6(tmp, `${JSON.stringify(moved, null, 2)}
15712
+ `);
15713
+ renameSync2(tmp, target);
15714
+ layout = { wrote: target, ids: Object.values(moved.lenses).reduce((n, l) => n + Object.keys(l).length, 0) };
15715
+ }
15716
+ return { declaration: declarationFile, layout };
15717
+ }
15718
+
14781
15719
  // src/export.ts
14782
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync6, copyFileSync, readFileSync as readFileSync20, readdirSync as readdirSync10, existsSync as existsSync18 } from "node:fs";
14783
- import { join as join26, dirname as dirname8, basename as basename3 } from "node:path";
15720
+ import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, copyFileSync, readFileSync as readFileSync21, readdirSync as readdirSync10, existsSync as existsSync20 } from "node:fs";
15721
+ import { join as join28, dirname as dirname9, basename as basename5 } from "node:path";
14784
15722
  import { fileURLToPath as fileURLToPath5 } from "node:url";
14785
15723
  var LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "stacks", "tier"];
14786
15724
  function canonicalKey(path, params) {
@@ -14829,31 +15767,31 @@ function captureKeys(axes) {
14829
15767
  return [...keys];
14830
15768
  }
14831
15769
  function webDir() {
14832
- return join26(dirname8(fileURLToPath5(import.meta.url)), "..", "web");
15770
+ return join28(dirname9(fileURLToPath5(import.meta.url)), "..", "web");
14833
15771
  }
14834
15772
  function pkgRoot2() {
14835
- return join26(dirname8(fileURLToPath5(import.meta.url)), "..");
15773
+ return join28(dirname9(fileURLToPath5(import.meta.url)), "..");
14836
15774
  }
14837
15775
  var NOTICE_FILES = ["LICENSE", "THIRD_PARTY.md"];
14838
15776
  function copyNotices(outDir, root = pkgRoot2()) {
14839
15777
  const copied = [];
14840
15778
  for (const f of NOTICE_FILES) {
14841
- if (!existsSync18(join26(root, f))) continue;
14842
- copyFileSync(join26(root, f), join26(outDir, f));
15779
+ if (!existsSync20(join28(root, f))) continue;
15780
+ copyFileSync(join28(root, f), join28(outDir, f));
14843
15781
  copied.push(f);
14844
15782
  }
14845
- const lic = join26(root, "licenses");
14846
- if (existsSync18(lic)) {
14847
- mkdirSync7(join26(outDir, "licenses"), { recursive: true });
15783
+ const lic = join28(root, "licenses");
15784
+ if (existsSync20(lic)) {
15785
+ mkdirSync8(join28(outDir, "licenses"), { recursive: true });
14848
15786
  for (const f of readdirSync10(lic)) {
14849
- copyFileSync(join26(lic, f), join26(outDir, "licenses", f));
15787
+ copyFileSync(join28(lic, f), join28(outDir, "licenses", f));
14850
15788
  copied.push(`licenses/${f}`);
14851
15789
  }
14852
15790
  }
14853
15791
  return copied;
14854
15792
  }
14855
15793
  function workerName(project, override) {
14856
- const raw = override ?? `behold-${basename3(project)}`;
15794
+ const raw = override ?? `behold-${basename5(project)}`;
14857
15795
  const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
14858
15796
  return name || "behold-export";
14859
15797
  }
@@ -14861,8 +15799,8 @@ async function runExport(cfg, outDir, opts = {}) {
14861
15799
  const app = createApp({ ...cfg, layoutWrites: false });
14862
15800
  const proj = await (await app.request("/api/project")).json();
14863
15801
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [], ...proj.ops ? { ops: proj.ops } : {} };
14864
- const snapDir = join26(outDir, "snapshots");
14865
- mkdirSync7(snapDir, { recursive: true });
15802
+ const snapDir = join28(outDir, "snapshots");
15803
+ mkdirSync8(snapDir, { recursive: true });
14866
15804
  const keyToFile = {};
14867
15805
  let ok = 0;
14868
15806
  let failed = 0;
@@ -14870,7 +15808,7 @@ async function runExport(cfg, outDir, opts = {}) {
14870
15808
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
14871
15809
  const body = await res.text();
14872
15810
  const file = slug3(key);
14873
- writeFileSync6(join26(snapDir, file), body);
15811
+ writeFileSync7(join28(snapDir, file), body);
14874
15812
  keyToFile[key] = `snapshots/${file}`;
14875
15813
  if (res.ok) ok++;
14876
15814
  else failed++;
@@ -14882,22 +15820,22 @@ async function runExport(cfg, outDir, opts = {}) {
14882
15820
  axes,
14883
15821
  keyToFile
14884
15822
  };
14885
- writeFileSync6(join26(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
14886
- const html = readFileSync20(join26(webDir(), "index.html"), "utf8").replace(
15823
+ writeFileSync7(join28(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
15824
+ const html = readFileSync21(join28(webDir(), "index.html"), "utf8").replace(
14887
15825
  /<\/head>/i,
14888
15826
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
14889
15827
  </head>`
14890
15828
  );
14891
- writeFileSync6(join26(outDir, "index.html"), html);
15829
+ writeFileSync7(join28(outDir, "index.html"), html);
14892
15830
  for (const f of readdirSync10(webDir())) {
14893
15831
  if (f === "index.html") continue;
14894
- copyFileSync(join26(webDir(), f), join26(outDir, f));
15832
+ copyFileSync(join28(webDir(), f), join28(outDir, f));
14895
15833
  }
14896
- writeFileSync6(join26(outDir, "README.md"), BUNDLE_README);
15834
+ writeFileSync7(join28(outDir, "README.md"), BUNDLE_README);
14897
15835
  copyNotices(outDir);
14898
15836
  const name = workerName(cfg.projectDir, opts.name);
14899
- writeFileSync6(
14900
- join26(outDir, "wrangler.jsonc"),
15837
+ writeFileSync7(
15838
+ join28(outDir, "wrangler.jsonc"),
14901
15839
  JSON.stringify(
14902
15840
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
14903
15841
  null,
@@ -14949,15 +15887,16 @@ init_project();
14949
15887
  init_member_kind();
14950
15888
  init_choudoufu_member();
14951
15889
  init_carve_lens();
15890
+ init_terraform_member();
14952
15891
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
14953
15892
 
14954
15893
  Usage:
14955
15894
  behold demo [name] [target-dir] [--port <n>] [--list]
14956
15895
  behold demo carve [--live] [--port <n>]
14957
- behold doctor [project-dir] [--json]
15896
+ behold doctor [project-dir] [--json] [--fix]
14958
15897
  behold preview [project-dir] [--port <n>] [--emulator]
14959
15898
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
14960
- behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
15899
+ behold serve <project-dir\u2026> [--port <n>] [--host <addr>] [--allow-host <name,\u2026>] [--env <name>] [--poll <secs>] [--local]
14961
15900
  behold carve <report.json> [--port <n>]
14962
15901
 
14963
15902
  carve Render a chant Terraform peelability report \u2014 the JSON from
@@ -15016,6 +15955,12 @@ Usage:
15016
15955
 
15017
15956
  Options:
15018
15957
  --port <n> Port (default 4600). preview/serve/carve.
15958
+ --host <addr> serve only: the address to bind (default 127.0.0.1, or
15959
+ BEHOLD_HOST). behold runs writes with your credentials,
15960
+ so it answers this machine unless you say otherwise.
15961
+ --allow-host <n,\u2026> serve only: names besides loopback behold answers to, and
15962
+ whose pages may write (BEHOLD_ALLOWED_HOSTS too). For a
15963
+ proxy that frames behold, or --host 0.0.0.0.
15019
15964
  --env <name> Environment name \u2014 turns on the live drift overlay.
15020
15965
  export/serve.
15021
15966
  --poll <secs> Re-query live drift every <secs> and push updates (needs --env).
@@ -15048,6 +15993,10 @@ Options:
15048
15993
  with chant's generic \`chant emulator up\`.
15049
15994
  --json doctor only: the report as JSON (stable keys) instead of
15050
15995
  the console lines \u2014 the AGENTS.md audience.
15996
+ --fix doctor only: write chant.workspace.json for an estate
15997
+ root that lists its members in .behold.json, move its
15998
+ saved layout to the root, and run chant workspace check.
15999
+ The one doctor that writes (#464).
15051
16000
  --out <dir> export only: output directory (default ./behold-export).
15052
16001
  --name <worker> export only: Cloudflare Worker name in the generated
15053
16002
  wrangler.jsonc.
@@ -15096,9 +16045,13 @@ ${USAGE}`);
15096
16045
  let pollSecs;
15097
16046
  let autoSync = "off";
15098
16047
  let local = false;
16048
+ let host;
16049
+ const allowHosts = [];
15099
16050
  for (let i = 0; i < rest.length; i++) {
15100
16051
  const a = rest[i];
15101
16052
  if (a === "--port") port = Number(rest[++i]);
16053
+ else if (a === "--host") host = rest[++i];
16054
+ else if (a === "--allow-host") allowHosts.push(...(rest[++i] ?? "").split(","));
15102
16055
  else if (a === "--env") env = rest[++i];
15103
16056
  else if (a === "--poll") pollSecs = Number(rest[++i]);
15104
16057
  else if (a === "--local") local = true;
@@ -15139,7 +16092,24 @@ ${USAGE}`);
15139
16092
  process.stderr.write("behold serve: --auto-sync needs --env and --poll (it acts on polled drift)\n");
15140
16093
  process.exit(2);
15141
16094
  }
15142
- const dirs = projectDirs.map((d) => resolve14(d));
16095
+ const dirs = projectDirs.map((d) => resolve18(d));
16096
+ const workspace = dirs.length === 1 && hasDeclaration(dirs[0]) ? await declaredWorkspace(dirs[0]) : void 0;
16097
+ if (workspace) {
16098
+ const drawn = drawnMembers(workspace).map((m) => m.abs);
16099
+ await startServer({
16100
+ projectDir: drawn[0] ?? workspace.root,
16101
+ projectDirs: drawn,
16102
+ workspace,
16103
+ port,
16104
+ ...env ? { env } : {},
16105
+ ...pollSecs !== void 0 ? { pollSecs } : {},
16106
+ ...autoSync !== "off" ? { autoSync } : {},
16107
+ ...local ? { local: true } : {},
16108
+ ...host ? { host } : {},
16109
+ ...allowHosts.length ? { allowedHosts: allowHosts } : {}
16110
+ });
16111
+ return;
16112
+ }
15143
16113
  for (const d of dirs) warnIfNotChantProject(d);
15144
16114
  await startServer({
15145
16115
  projectDir: dirs[0],
@@ -15153,9 +16123,33 @@ ${USAGE}`);
15153
16123
  ...env ? { env } : {},
15154
16124
  ...pollSecs !== void 0 ? { pollSecs } : {},
15155
16125
  ...autoSync !== "off" ? { autoSync } : {},
15156
- ...local ? { local: true } : {}
16126
+ ...local ? { local: true } : {},
16127
+ ...host ? { host } : {},
16128
+ ...allowHosts.length ? { allowedHosts: allowHosts } : {}
15157
16129
  });
15158
16130
  }
16131
+ async function declaredWorkspace(root) {
16132
+ const read = await readWorkspace(root);
16133
+ if (!read.ok) {
16134
+ process.stderr.write(`behold serve: ${read.refusal.error}
16135
+ ${read.refusal.remedy}
16136
+ `);
16137
+ process.exit(1);
16138
+ }
16139
+ const ws = read.workspace;
16140
+ const drawn = drawnMembers(ws);
16141
+ process.stdout.write(`behold: serving chant workspace ${ws.name} (${ws.file}), ${drawn.length} of ${ws.members.length} members drawn
16142
+ `);
16143
+ for (const m of unreadableMembers(ws)) process.stdout.write(` ${m.name}: unreadable, ${m.reason.code}: ${m.reason.message}
16144
+ `);
16145
+ for (const m of undrawnMembers(ws)) process.stdout.write(` ${m.name}: kind ${m.kind}, listed and not drawn${m.because ? ` (${m.because})` : ""}
16146
+ `);
16147
+ if (loadBeholdConfig(root).members) {
16148
+ process.stderr.write(`behold: warning \u2014 ${root}/.behold.json lists members, and chant.workspace.json does too; the declaration wins and .behold.json's members are ignored.
16149
+ `);
16150
+ }
16151
+ return ws;
16152
+ }
15159
16153
  function warnIfNotChantProject(dir) {
15160
16154
  const shape = detectProjectShape(dir);
15161
16155
  if (shape.kind === "project") return;
@@ -15163,7 +16157,7 @@ function warnIfNotChantProject(dir) {
15163
16157
  if (shape.kind === "estate") {
15164
16158
  process.stderr.write(
15165
16159
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
15166
- Serve its members composed: behold serve ${shape.members.map((m) => join27(dir, m.dir)).join(" ")}
16160
+ Serve its members composed: behold serve ${shape.members.map((m) => join29(dir, m.dir)).join(" ")}
15167
16161
  `
15168
16162
  );
15169
16163
  return;
@@ -15198,8 +16192,8 @@ async function runCarve(rest) {
15198
16192
  );
15199
16193
  process.exit(2);
15200
16194
  }
15201
- const reportPath = resolve14(fileArg);
15202
- const parsed = readCarveReport(reportPath, (p) => readFileSync21(p, "utf8"));
16195
+ const reportPath = resolve18(fileArg);
16196
+ const parsed = readCarveReport(reportPath, (p) => readFileSync22(p, "utf8"));
15203
16197
  if (!parsed.ok) {
15204
16198
  process.stderr.write(`behold carve: ${parsed.refusal.error}
15205
16199
  ${parsed.refusal.remedy}
@@ -15210,13 +16204,15 @@ async function runCarve(rest) {
15210
16204
  `behold carve \u2014 ${parsed.report.resources.length} resource(s)/module(s) ranked${parsed.report.from ? ` from ${parsed.report.from}` : ""}
15211
16205
  `
15212
16206
  );
15213
- await startServer({ projectDir: dirname9(reportPath), carveReport: reportPath, port });
16207
+ await startServer({ projectDir: dirname10(reportPath), carveReport: reportPath, port });
15214
16208
  }
15215
16209
  async function runDoctor(rest) {
15216
16210
  let json = false;
16211
+ let fix = false;
15217
16212
  let dirArg;
15218
16213
  for (const a of rest) {
15219
16214
  if (a === "--json") json = true;
16215
+ else if (a === "--fix") fix = true;
15220
16216
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
15221
16217
  else if (!a.startsWith("-")) dirArg = a;
15222
16218
  else {
@@ -15226,17 +16222,44 @@ async function runDoctor(rest) {
15226
16222
  }
15227
16223
  }
15228
16224
  const dir = dirArg ?? ".";
15229
- if (!existsSync19(resolve14(dir))) {
15230
- process.stderr.write(`behold doctor: no such directory: ${resolve14(dir)}
16225
+ if (!existsSync21(resolve18(dir))) {
16226
+ process.stderr.write(`behold doctor: no such directory: ${resolve18(dir)}
15231
16227
  `);
15232
16228
  process.exit(2);
15233
16229
  }
16230
+ if (fix) return runDoctorFix(dir);
15234
16231
  const report = await diagnose(dir);
15235
16232
  process.stdout.write(json ? JSON.stringify(report, null, 2) + "\n" : formatReport(report));
15236
16233
  if (!report.ok) process.exitCode = 1;
15237
16234
  }
16235
+ async function runDoctorFix(dir) {
16236
+ const planned = planConversion(dir);
16237
+ if (!planned.ok) {
16238
+ process.stderr.write(`behold doctor --fix: ${planned.error}
16239
+ `);
16240
+ process.exitCode = 1;
16241
+ return;
16242
+ }
16243
+ const { plan } = planned;
16244
+ const wrote = applyConversion(plan);
16245
+ process.stdout.write(`wrote ${wrote.declaration} (${plan.declaration.members.length} members)
16246
+ `);
16247
+ if ("wrote" in wrote.layout) process.stdout.write(`moved the saved layout to ${wrote.layout.wrote} (${wrote.layout.ids} node ids)
16248
+ `);
16249
+ else process.stdout.write(`layout: ${wrote.layout.skipped}
16250
+ `);
16251
+ for (const n of plan.notes) process.stdout.write(`note: ${n}
16252
+ `);
16253
+ if (plan.from === "behold-config") process.stdout.write(`.behold.json's members are ignored from now on, since the declaration lists them; you can delete them.
16254
+ `);
16255
+ const check = await runChantRaw(["workspace", "check", "--format", "json"], plan.root);
16256
+ process.stdout.write(check.code === 0 ? "chant workspace check: passed\n" : `chant workspace check failed (exit ${check.code}):
16257
+ ${check.stdout || check.stderr}
16258
+ `);
16259
+ if (check.code !== 0) process.exitCode = 1;
16260
+ }
15238
16261
  async function runDemo(rest) {
15239
- const pkgRoot3 = join27(dirname9(fileURLToPath6(import.meta.url)), "..");
16262
+ const pkgRoot3 = join29(dirname10(fileURLToPath6(import.meta.url)), "..");
15240
16263
  const registry2 = loadDemoRegistry(pkgRoot3);
15241
16264
  let port = 4600;
15242
16265
  let live2 = false;
@@ -15299,7 +16322,7 @@ async function runDemo(rest) {
15299
16322
  `);
15300
16323
  process.exit(2);
15301
16324
  }
15302
- const target = dirArg ? resolve14(dirArg) : demoTargetDir(entry);
16325
+ const target = dirArg ? resolve18(dirArg) : demoTargetDir(entry);
15303
16326
  const loaded = await loadDemo(entry, { pkgRoot: pkgRoot3, target, log: (line) => process.stdout.write(line + "\n") });
15304
16327
  if (!loaded.ok) {
15305
16328
  process.stderr.write(`behold demo ${entry.name}: ${loaded.error}
@@ -15331,7 +16354,7 @@ function spawnStep(cmd, args, cwd) {
15331
16354
  });
15332
16355
  }
15333
16356
  async function serveCarveDemo(target, carve, port, live2 = false) {
15334
- const at = (rel) => resolve14(target, rel);
16357
+ const at = (rel) => resolve18(target, rel);
15335
16358
  const project = at(carve.project);
15336
16359
  const from = at(carve.from);
15337
16360
  const state = carve.state ? at(carve.state) : void 0;
@@ -15371,7 +16394,7 @@ async function serveCarveDemo(target, carve, port, live2 = false) {
15371
16394
  };
15372
16395
  process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
15373
16396
  }
15374
- if (existsSync19(join27(project, "package.json")) && !existsSync19(join27(project, "node_modules"))) {
16397
+ if (existsSync21(join29(project, "package.json")) && !existsSync21(join29(project, "node_modules"))) {
15375
16398
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
15376
16399
  `);
15377
16400
  const code = await spawnStep("npm", ["install"], project);
@@ -15382,10 +16405,11 @@ async function serveCarveDemo(target, carve, port, live2 = false) {
15382
16405
  }
15383
16406
  }
15384
16407
  let degraded;
15385
- if (!existsSync19(join27(target, "node_modules", "@cdktf", "hcl2json"))) {
15386
- process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
15387
- const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
15388
- if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
16408
+ if (!existsSync21(join29(target, "node_modules", ...HCL_PARSER_PKG.split("/")))) {
16409
+ process.stdout.write(`behold demo carve \u2192 npm install ${HCL_PARSER_PKG} (chant's HCL parser, ~2MB, once)\u2026
16410
+ `);
16411
+ const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", HCL_PARSER_PKG], target);
16412
+ if (code !== 0) degraded = `couldn't install ${HCL_PARSER_PKG} (chant's HCL parser) \u2014 no network?`;
15389
16413
  }
15390
16414
  const report = at("carve-report.json");
15391
16415
  if (!degraded) {
@@ -15398,7 +16422,7 @@ async function serveCarveDemo(target, carve, port, live2 = false) {
15398
16422
  );
15399
16423
  if (code !== 0) degraded = `chant carve advise exited ${code}`;
15400
16424
  }
15401
- const serving = !degraded && existsSync19(report) ? report : committed;
16425
+ const serving = !degraded && existsSync21(report) ? report : committed;
15402
16426
  if (degraded) {
15403
16427
  process.stderr.write(
15404
16428
  `behold demo carve: ${degraded}
@@ -15406,7 +16430,7 @@ async function serveCarveDemo(target, carve, port, live2 = false) {
15406
16430
  `
15407
16431
  );
15408
16432
  }
15409
- if (!existsSync19(serving)) {
16433
+ if (!existsSync21(serving)) {
15410
16434
  process.stderr.write(`behold demo carve: no carve report at ${serving}
15411
16435
  `);
15412
16436
  process.exit(2);
@@ -15451,8 +16475,8 @@ async function runPreview(rest) {
15451
16475
  process.stderr.write("behold preview: --port must be a number\n");
15452
16476
  process.exit(2);
15453
16477
  }
15454
- const projectDir = resolve14(dirArg ?? process.cwd());
15455
- if (!existsSync19(projectDir)) {
16478
+ const projectDir = resolve18(dirArg ?? process.cwd());
16479
+ if (!existsSync21(projectDir)) {
15456
16480
  process.stderr.write(`behold preview: project not found at ${projectDir}
15457
16481
  `);
15458
16482
  process.exit(2);
@@ -15472,26 +16496,26 @@ async function runPreview(rest) {
15472
16496
  await startServer({ projectDir, port, env: "local", previewMode: true });
15473
16497
  }
15474
16498
  async function runExportCmd(rest) {
15475
- let outDir = resolve14("behold-export");
16499
+ let outDir = resolve18("behold-export");
15476
16500
  let env;
15477
16501
  let name;
15478
16502
  let dirArg;
15479
16503
  let emulator = false;
15480
16504
  for (let i = 0; i < rest.length; i++) {
15481
16505
  const a = rest[i];
15482
- if (a === "--out") outDir = resolve14(rest[++i]);
16506
+ if (a === "--out") outDir = resolve18(rest[++i]);
15483
16507
  else if (a === "--env") env = rest[++i];
15484
16508
  else if (a === "--name") name = rest[++i];
15485
16509
  else if (a === "--emulator") emulator = true;
15486
16510
  else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
15487
16511
  else if (!a.startsWith("-")) dirArg = a;
15488
16512
  }
15489
- const projectDir = resolve14(dirArg ?? process.cwd());
16513
+ const projectDir = resolve18(dirArg ?? process.cwd());
15490
16514
  if (emulator) {
15491
16515
  injectEmulatorEnv(env);
15492
16516
  env ??= "local";
15493
16517
  }
15494
- if (!existsSync19(projectDir)) {
16518
+ if (!existsSync21(projectDir)) {
15495
16519
  process.stderr.write(`behold export: project not found at ${projectDir}
15496
16520
  `);
15497
16521
  process.exit(2);