@intentius/behold 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1038,10 +1038,10 @@ var init_src = __esm({
1038
1038
  });
1039
1039
 
1040
1040
  // src/cli.ts
1041
- import { resolve as resolve7, dirname as dirname6, join as join17 } from "node:path";
1042
- import { realpathSync, existsSync as existsSync13, readFileSync as readFileSync14 } from "node:fs";
1041
+ import { resolve as resolve7, dirname as dirname6, join as join18 } from "node:path";
1042
+ import { realpathSync, existsSync as existsSync14, readFileSync as readFileSync15, rmSync as rmSync2 } from "node:fs";
1043
1043
  import { fileURLToPath as fileURLToPath5 } from "node:url";
1044
- import { spawn as spawn5 } from "node:child_process";
1044
+ import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
1045
1045
 
1046
1046
  // src/server.ts
1047
1047
  import { Hono } from "hono";
@@ -1120,8 +1120,8 @@ import { serve } from "@hono/node-server";
1120
1120
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1121
1121
  import { execFile as execFile3 } from "node:child_process";
1122
1122
  import { promisify as promisify3 } from "node:util";
1123
- import { existsSync as existsSync12, readFileSync as readFileSync12 } from "node:fs";
1124
- import { dirname as dirname4, join as join15, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1123
+ import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
1124
+ import { dirname as dirname4, join as join16, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1125
1125
 
1126
1126
  // src/recents.ts
1127
1127
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -3872,7 +3872,14 @@ function summarizePlan(plan, byComponent, nonResource) {
3872
3872
  }
3873
3873
 
3874
3874
  // src/render.ts
3875
- import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack } from "@intentius/pinhole";
3875
+ import {
3876
+ layoutIr,
3877
+ layoutArchitecture,
3878
+ renderSvg,
3879
+ renderMorphHtml,
3880
+ cardSizes,
3881
+ registerPack
3882
+ } from "@intentius/pinhole";
3876
3883
 
3877
3884
  // src/icon-packs.ts
3878
3885
  import { readFileSync as readFileSync6 } from "node:fs";
@@ -4189,16 +4196,32 @@ function renderGraph(ir, opts = {}) {
4189
4196
  return { svg };
4190
4197
  }
4191
4198
  function renderBanded(ir, opts = {}) {
4192
- const bands = ir.groups.byStack ?? {};
4199
+ const plan = bandedPlan(ir, ir.groups.byStack ?? {});
4200
+ const height = plan.height;
4201
+ const layout = {
4202
+ width: plan.width,
4203
+ height,
4204
+ nodes: plan.placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
4205
+ };
4206
+ const svg = renderSvg(ir, layout, {
4207
+ fit: true,
4208
+ hideTitle: true,
4209
+ groups: plan.boxes.map((b) => ({ ...b, y: height - b.y })),
4210
+ ...opts.theme ? { theme: opts.theme } : {}
4211
+ });
4212
+ return { svg };
4213
+ }
4214
+ var GAP = 28;
4215
+ var PAD = 24;
4216
+ var TITLE = 34;
4217
+ var BAND_GAP = 26;
4218
+ var MEMBER_GAP = 48;
4219
+ function bandedPlan(ir, bands) {
4193
4220
  const size = footprints(ir);
4194
4221
  const dims = (id) => size.get(id) ?? { w: NODE_W, h: NODE_H };
4195
4222
  const ids = ir.nodes.map((n) => n.id);
4196
4223
  const cellW = Math.max(NODE_W, ...ids.map((id) => dims(id).w));
4197
4224
  const cellH = Math.max(NODE_H, ...ids.map((id) => dims(id).h));
4198
- const GAP = 28;
4199
- const PAD = 24;
4200
- const TITLE = 34;
4201
- const BAND_GAP = 26;
4202
4225
  const cols = Math.max(1, Math.round(Math.sqrt(ids.length * (cellH + GAP) * 2 / (cellW + GAP))));
4203
4226
  const contentW = cols * cellW + (cols - 1) * GAP;
4204
4227
  const panelW = contentW + PAD * 2;
@@ -4227,19 +4250,95 @@ function renderBanded(ir, opts = {}) {
4227
4250
  boxes.push({ title, x: panelW / 2, y: top + panelH / 2, w: panelW, h: panelH, ...status ? { status } : {} });
4228
4251
  top += panelH + BAND_GAP;
4229
4252
  }
4230
- const height = Math.max(1, top - BAND_GAP);
4231
- const layout = {
4232
- width: panelW,
4233
- height,
4234
- nodes: placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
4235
- };
4236
- const svg = renderSvg(ir, layout, {
4253
+ return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) };
4254
+ }
4255
+ function renderCarveEstate(tfIr, appIr, opts) {
4256
+ const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts);
4257
+ const svg = renderSvg(comp.ir, comp.layout, {
4237
4258
  fit: true,
4238
4259
  hideTitle: true,
4239
- groups: boxes.map((b) => ({ ...b, y: height - b.y })),
4260
+ groups: comp.boxes,
4240
4261
  ...opts.theme ? { theme: opts.theme } : {}
4241
4262
  });
4242
- return { svg };
4263
+ return { svg, ir: comp.ir };
4264
+ }
4265
+ function namespaceAppIr(appIr, appTitle) {
4266
+ const prefix = `${appTitle.split(" ")[0]}/`;
4267
+ const appId = (id) => `${prefix}${id}`;
4268
+ return {
4269
+ ...appIr,
4270
+ nodes: appIr.nodes.map((n) => ({ ...n, id: appId(n.id) })),
4271
+ edges: appIr.edges.map((e) => ({ ...e, from: appId(e.from), to: appId(e.to) })),
4272
+ groups: {}
4273
+ };
4274
+ }
4275
+ function composeCarveEstate(tfIr, appNs, opts) {
4276
+ const tfPlan = bandedPlan(tfIr, tfIr.groups.byStack ?? {});
4277
+ const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
4278
+ const memberH = (plan) => TITLE + PAD + plan.height + PAD;
4279
+ const memberW = (plan) => plan.width + PAD * 2;
4280
+ const appX0 = memberW(tfPlan) + MEMBER_GAP;
4281
+ const width = appX0 + memberW(appPlan);
4282
+ const height = Math.max(memberH(tfPlan), memberH(appPlan));
4283
+ const offset = (plan, x0) => ({
4284
+ placed: plan.placed.map((p) => ({ id: p.id, x: x0 + PAD + p.x, y: TITLE + PAD + p.y })),
4285
+ boxes: plan.boxes.map((b) => ({ ...b, x: x0 + PAD + b.x, y: TITLE + PAD + b.y }))
4286
+ });
4287
+ const tf = offset(tfPlan, 0);
4288
+ const app = offset(appPlan, appX0);
4289
+ const boxes = [
4290
+ { title: opts.tfTitle, x: memberW(tfPlan) / 2, y: memberH(tfPlan) / 2, w: memberW(tfPlan), h: memberH(tfPlan) },
4291
+ { title: opts.appTitle, x: appX0 + memberW(appPlan) / 2, y: memberH(appPlan) / 2, w: memberW(appPlan), h: memberH(appPlan) },
4292
+ ...tf.boxes,
4293
+ ...app.boxes
4294
+ ];
4295
+ const ir = {
4296
+ nodes: [...tfIr.nodes, ...appNs.nodes],
4297
+ edges: [...tfIr.edges, ...appNs.edges],
4298
+ groups: {
4299
+ byStack: {
4300
+ ...tfIr.groups.byStack ?? {},
4301
+ [opts.appTitle]: appNs.nodes.map((n) => n.id)
4302
+ }
4303
+ }
4304
+ };
4305
+ return {
4306
+ ir,
4307
+ layout: {
4308
+ width,
4309
+ height,
4310
+ nodes: [...tf.placed, ...app.placed].map((p) => ({ id: p.id, x: p.x, y: height - p.y }))
4311
+ },
4312
+ boxes: boxes.map((b) => ({ ...b, y: height - b.y }))
4313
+ };
4314
+ }
4315
+ function renderCarveMorph(tfIr, appIr, carved, opts) {
4316
+ const appNs = namespaceAppIr(appIr, opts.appTitle);
4317
+ const before = composeCarveEstate(tfIr, appNs, opts);
4318
+ const gone = new Set(carved);
4319
+ const bands = tfIr.groups.byStack ?? {};
4320
+ const bandsAfter = {};
4321
+ for (const [band, members] of Object.entries(bands)) {
4322
+ const left = members.filter((id) => !gone.has(id));
4323
+ if (left.length) bandsAfter[band] = left;
4324
+ }
4325
+ const tfAfter = {
4326
+ nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
4327
+ edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
4328
+ groups: { byStack: bandsAfter }
4329
+ };
4330
+ const carvedNodes = tfIr.nodes.filter((n) => gone.has(n.id)).map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved \u2192 chant" } }));
4331
+ const appAfter = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
4332
+ const after = composeCarveEstate(tfAfter, appAfter, opts);
4333
+ const view = (name, comp) => ({
4334
+ name,
4335
+ ir: comp.ir,
4336
+ layout: comp.layout,
4337
+ groups: comp.boxes
4338
+ });
4339
+ return renderMorphHtml([view("Terraform owns it", before), view("chant owns it", after)], {
4340
+ title: opts.title ?? `carve morph \u2014 ${carved.join(", ")}`
4341
+ });
4243
4342
  }
4244
4343
  var NODE_W = 175;
4245
4344
  var NODE_H = 104;
@@ -4406,8 +4505,8 @@ function radializeLayout(layout, groupOf, size = /* @__PURE__ */ new Map()) {
4406
4505
  }
4407
4506
 
4408
4507
  // src/carve-actions.ts
4409
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4410
- import { isAbsolute, join as join9, relative, resolve as resolve2, sep } from "node:path";
4508
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
4509
+ import { isAbsolute, join as join10, relative, resolve as resolve2, sep } from "node:path";
4411
4510
 
4412
4511
  // src/layout.ts
4413
4512
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync, rmSync, writeFileSync as writeFileSync2, accessSync, constants } from "node:fs";
@@ -4591,7 +4690,112 @@ function reanchorEdges(svg, moved) {
4591
4690
  return out + svg.slice(cursor);
4592
4691
  }
4593
4692
 
4693
+ // src/carve-live.ts
4694
+ import { spawn as spawn3 } from "node:child_process";
4695
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
4696
+ import { join as join9 } from "node:path";
4697
+ var LIVE_CONTAINER = "behold-carve-floci";
4698
+ var LIVE_PORT = 4602;
4699
+ var LIVE_IMAGE = "floci/floci:1.5.34";
4700
+ var LIVE_TARGETS = [
4701
+ "aws_s3_bucket.assets",
4702
+ "aws_s3_bucket_versioning.assets",
4703
+ "aws_s3_bucket_public_access_block.assets",
4704
+ "aws_cloudwatch_log_group.worker"
4705
+ ];
4706
+ function armOverride(disabledText, port) {
4707
+ return disabledText.replaceAll("localhost:4566", `localhost:${port}`);
4708
+ }
4709
+ function parsePlanOutput(stdout, exitCode) {
4710
+ const planLine = stdout.split("\n").reverse().find((l) => l.startsWith("Plan:") || l.includes("No changes.")) ?? `terraform plan exited ${exitCode}`;
4711
+ const destroyMatch = planLine.match(/(\d+) to destroy/);
4712
+ return {
4713
+ planLine: planLine.trim(),
4714
+ changes: exitCode === 2,
4715
+ noDestroy: !destroyMatch || destroyMatch[1] === "0"
4716
+ };
4717
+ }
4718
+ function capture(cmd, args, cwd) {
4719
+ return new Promise((res) => {
4720
+ const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd, shell: process.platform === "win32" });
4721
+ let out = "";
4722
+ child.stdout?.on("data", (d) => out += d.toString());
4723
+ child.stderr?.on("data", (d) => out += d.toString());
4724
+ child.on("error", () => res({ code: -1, stdout: out }));
4725
+ child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
4726
+ });
4727
+ }
4728
+ async function bootScratchFloci() {
4729
+ const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
4730
+ if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
4731
+ if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
4732
+ return `container ${LIVE_CONTAINER} already exists \u2014 a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`;
4733
+ }
4734
+ const run3 = await capture(
4735
+ "docker",
4736
+ ["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE],
4737
+ process.cwd()
4738
+ );
4739
+ if (run3.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run3.code})`;
4740
+ for (let i = 0; i < 30; i++) {
4741
+ try {
4742
+ const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`);
4743
+ if (health.ok && (await health.text()).includes("cloudformation")) return void 0;
4744
+ } catch {
4745
+ }
4746
+ await new Promise((r) => setTimeout(r, 1e3));
4747
+ }
4748
+ await teardownScratchFloci();
4749
+ return `Floci never reported healthy on :${LIVE_PORT} after 30s`;
4750
+ }
4751
+ async function teardownScratchFloci() {
4752
+ await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => void 0);
4753
+ }
4754
+ async function applyIntoFloci(fromDir, step) {
4755
+ const template = join9(fromDir, "floci-override.tf.disabled");
4756
+ if (!existsSync7(template)) return `no floci-override.tf.disabled in ${fromDir} \u2014 this estate wasn't authored for the live tier`;
4757
+ writeFileSync3(join9(fromDir, "floci_override.tf"), armOverride(readFileSync8(template, "utf8"), LIVE_PORT));
4758
+ const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir);
4759
+ if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`;
4760
+ const apply = await step(
4761
+ "terraform",
4762
+ ["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)],
4763
+ fromDir
4764
+ );
4765
+ if (apply !== 0) return `terraform apply exited ${apply}`;
4766
+ return void 0;
4767
+ }
4768
+ async function runLivePlan(fromDir) {
4769
+ const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir);
4770
+ if (r.code !== 0 && r.code !== 2) {
4771
+ const tail = r.stdout.trim().split("\n").slice(-8).join("\n");
4772
+ return { error: `terraform plan failed (exit ${r.code}):
4773
+ ${tail}`, exitCode: r.code };
4774
+ }
4775
+ return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code };
4776
+ }
4777
+
4594
4778
  // src/carve-actions.ts
4779
+ async function runCarvePlan(demo) {
4780
+ if (!demo?.live) {
4781
+ return refuse2(
4782
+ "carve-action",
4783
+ "terraform plan is the live tier's beat, and this server isn't serving one",
4784
+ "run `behold demo carve --live` (needs docker + terraform)"
4785
+ );
4786
+ }
4787
+ const r = await runLivePlan(demo.from);
4788
+ if ("error" in r) {
4789
+ return refuse2("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".");
4790
+ }
4791
+ return {
4792
+ ok: true,
4793
+ command: "terraform plan -detailed-exitcode (targeted, in the demo copy)",
4794
+ planLine: r.planLine,
4795
+ changes: r.changes,
4796
+ noDestroy: r.noDestroy
4797
+ };
4798
+ }
4595
4799
  var MAX_ARTIFACT_BYTES = 64 * 1024;
4596
4800
  var MAX_ARTIFACTS = 24;
4597
4801
  var MAX_OUTPUT_BYTES = 32 * 1024;
@@ -4612,7 +4816,7 @@ function carveWriteBlock(demo) {
4612
4816
  if (!demo) {
4613
4817
  return "this server isn't running a carve demo \u2014 the carve actions only exist inside a `behold demo carve` copy";
4614
4818
  }
4615
- if (!existsSync7(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
4819
+ if (!existsSync8(demo.from)) return `the demo copy has no Terraform estate at ${demo.from}`;
4616
4820
  if (!insideDemo(demo.root, demo.out)) return "the carve output directory is outside the demo copy";
4617
4821
  return unwritableReason(demo.root);
4618
4822
  }
@@ -4629,7 +4833,7 @@ function readArtifacts(demo, dir, filter) {
4629
4833
  for (const name of entries) {
4630
4834
  if (out.length >= MAX_ARTIFACTS) return;
4631
4835
  if (name === "node_modules" || name.startsWith(".")) continue;
4632
- const full = join9(d, name);
4836
+ const full = join10(d, name);
4633
4837
  let st;
4634
4838
  try {
4635
4839
  st = statSync2(full);
@@ -4644,7 +4848,7 @@ function readArtifacts(demo, dir, filter) {
4644
4848
  if (!filter(rel)) continue;
4645
4849
  let text = "";
4646
4850
  try {
4647
- text = readFileSync8(full, "utf8");
4851
+ text = readFileSync9(full, "utf8");
4648
4852
  } catch {
4649
4853
  continue;
4650
4854
  }
@@ -4676,7 +4880,7 @@ async function runCarveEmit(demo, select) {
4676
4880
  const block = carveWriteBlock(demo);
4677
4881
  if (block) return refuse2("read-only", block, "Start the walkthrough with `behold demo carve`.");
4678
4882
  mkdirSync3(demo.out, { recursive: true });
4679
- const reportFile = join9(demo.out, `${carveSlug(select)}-boundary.json`);
4883
+ const reportFile = join10(demo.out, `${carveSlug(select)}-boundary.json`);
4680
4884
  const args = [
4681
4885
  "carve",
4682
4886
  "emit",
@@ -4705,10 +4909,10 @@ async function runCarveEmit(demo, select) {
4705
4909
  }
4706
4910
  let boundary = null;
4707
4911
  try {
4708
- boundary = JSON.parse(readFileSync8(reportFile, "utf8"));
4912
+ boundary = JSON.parse(readFileSync9(reportFile, "utf8"));
4709
4913
  } catch {
4710
4914
  }
4711
- const lintPath = relative(demo.project, join9(demo.out, "src")).split(sep).join("/");
4915
+ const lintPath = relative(demo.project, join10(demo.out, "src")).split(sep).join("/");
4712
4916
  const lintArgs = ["lint", lintPath];
4713
4917
  const lint = await runChantRaw(lintArgs, demo.project).catch((err) => ({
4714
4918
  code: 127,
@@ -4771,8 +4975,8 @@ async function runCarveBridge(demo, select) {
4771
4975
  }
4772
4976
 
4773
4977
  // src/ops.ts
4774
- import { readdirSync as readdirSync4, readFileSync as readFileSync9, existsSync as existsSync8 } from "node:fs";
4775
- import { join as join10 } from "node:path";
4978
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10, existsSync as existsSync9 } from "node:fs";
4979
+ import { join as join11 } from "node:path";
4776
4980
  var APPLY_TARGET_LEXICON = {
4777
4981
  cloudformation: "aws",
4778
4982
  kubectl: "k8s",
@@ -4794,11 +4998,11 @@ function discoverOps(projectDir) {
4794
4998
  const seen = /* @__PURE__ */ new Set();
4795
4999
  const out = [];
4796
5000
  for (const sub of ["ops", "src", "."]) {
4797
- const dir = join10(projectDir, sub);
4798
- if (!existsSync8(dir)) continue;
5001
+ const dir = join11(projectDir, sub);
5002
+ if (!existsSync9(dir)) continue;
4799
5003
  for (const f of readdirSync4(dir)) {
4800
5004
  if (!f.endsWith(".op.ts")) continue;
4801
- const content = readFileSync9(join10(dir, f), "utf8");
5005
+ const content = readFileSync10(join11(dir, f), "utf8");
4802
5006
  const name = content.match(/name:\s*["'`]([^"'`]+)["'`]/)?.[1];
4803
5007
  if (!name || seen.has(name)) continue;
4804
5008
  seen.add(name);
@@ -5323,9 +5527,9 @@ var OpRunner = class {
5323
5527
  };
5324
5528
 
5325
5529
  // src/substrates.ts
5326
- import { spawn as spawn3 } from "node:child_process";
5327
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "node:fs";
5328
- import { join as join11 } from "node:path";
5530
+ import { spawn as spawn4 } from "node:child_process";
5531
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "node:fs";
5532
+ import { join as join12 } from "node:path";
5329
5533
  import { platform } from "node:os";
5330
5534
  var DOCKER_DOWN_DETAIL = "docker is down";
5331
5535
  function probe(cmd, args) {
@@ -5333,7 +5537,7 @@ function probe(cmd, args) {
5333
5537
  let out = "";
5334
5538
  let proc;
5335
5539
  try {
5336
- proc = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5540
+ proc = spawn4(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5337
5541
  } catch {
5338
5542
  resolve8({ code: 127, out: "" });
5339
5543
  return;
@@ -5354,11 +5558,11 @@ async function dockerRunning(run3, nameFilter) {
5354
5558
  return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
5355
5559
  }
5356
5560
  function scriptBringUp(projectDir, relPath, label) {
5357
- return existsSync9(join11(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5561
+ return existsSync10(join12(projectDir, relPath)) ? { label, cmd: "bash", args: [relPath] } : void 0;
5358
5562
  }
5359
5563
  function projectLexicons(projectDir) {
5360
5564
  try {
5361
- const src = readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8");
5565
+ const src = readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8");
5362
5566
  const m = src.match(/lexicons\s*:\s*\[([^\]]*)\]/);
5363
5567
  if (!m) return [];
5364
5568
  return [...m[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1]);
@@ -5410,7 +5614,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5410
5614
  ["forgejo", "Forgejo", ".forgejo", "test/forgejo-runtime-e2e.sh"]
5411
5615
  ];
5412
5616
  for (const [name, label, marker, script] of forges) {
5413
- if (!existsSync9(join11(projectDir, marker))) continue;
5617
+ if (!existsSync10(join12(projectDir, marker))) continue;
5414
5618
  const c = docker ? await dockerRunning(run3, name) : [];
5415
5619
  const d = dep(c.length > 0, "container up", "on-demand (pipeline run)");
5416
5620
  subs.push({
@@ -5444,7 +5648,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5444
5648
  detail: endpoint ? `targeting ${endpoint}` : "real Fly (FLY_FLAPS_BASE_URL unset)"
5445
5649
  });
5446
5650
  }
5447
- if (existsSync9(join11(projectDir, ".github", "workflows"))) {
5651
+ if (existsSync10(join12(projectDir, ".github", "workflows"))) {
5448
5652
  const gh = await run3("gh", ["auth", "status"]);
5449
5653
  const ready = gh.code === 0;
5450
5654
  subs.push({
@@ -5457,7 +5661,7 @@ async function detectSubstrates(projectDir, preview = false, boundContext, probe
5457
5661
  if (lexicons.includes("temporal")) {
5458
5662
  let hasProfiles = false;
5459
5663
  try {
5460
- hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync10(join11(projectDir, "chant.config.ts"), "utf-8"));
5664
+ hasProfiles = /temporal\s*:\s*\{[\s\S]{0,400}?profiles\s*:/.test(readFileSync11(join12(projectDir, "chant.config.ts"), "utf-8"));
5461
5665
  } catch {
5462
5666
  }
5463
5667
  subs.push({
@@ -5562,7 +5766,7 @@ async function openRollbackBranches(projectDir, env) {
5562
5766
 
5563
5767
  // src/estate.ts
5564
5768
  import { statSync as statSync3 } from "node:fs";
5565
- import { join as join12, resolve as resolve3, sep as sep2 } from "node:path";
5769
+ import { join as join13, resolve as resolve3, sep as sep2 } from "node:path";
5566
5770
  import { composeStacks, shortStackNames } from "@intentius/pinhole";
5567
5771
  async function composeEstate(projectDirs, opts = {}) {
5568
5772
  const names = shortStackNames(projectDirs);
@@ -5622,7 +5826,7 @@ function pathAlignment(declared, dir, isDir = realIsDir) {
5622
5826
  const have = root.split(sep2).filter(Boolean);
5623
5827
  for (let k = Math.min(want.length, have.length); k >= 1; k--) {
5624
5828
  if (!have.slice(-k).every((s, i) => s === want[i])) continue;
5625
- if (!isDir(join12(root, ...want.slice(k)))) continue;
5829
+ if (!isDir(join13(root, ...want.slice(k)))) continue;
5626
5830
  return k;
5627
5831
  }
5628
5832
  return 0;
@@ -5652,10 +5856,10 @@ function joinNamespaceBindings(members, isDir = realIsDir) {
5652
5856
  if (!winner || tied) continue;
5653
5857
  const prev = best.get(winner.dir);
5654
5858
  if (prev === "ambiguous") continue;
5655
- const join18 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
5656
- if (!prev) best.set(winner.dir, { score: winner.score, join: join18 });
5859
+ const join19 = { dir: winner.dir, namespace: b.namespace, path: b.path, declaredBy: b.declaredBy };
5860
+ if (!prev) best.set(winner.dir, { score: winner.score, join: join19 });
5657
5861
  else if (prev.join.namespace !== b.namespace) best.set(winner.dir, "ambiguous");
5658
- else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join18 });
5862
+ else if (winner.score > prev.score) best.set(winner.dir, { score: winner.score, join: join19 });
5659
5863
  }
5660
5864
  return [...best.values()].filter((v) => v !== "ambiguous").map((v) => v.join);
5661
5865
  }
@@ -5717,8 +5921,8 @@ async function composeEstateOverlay(projectDirs, opts, classify) {
5717
5921
  }
5718
5922
 
5719
5923
  // src/events.ts
5720
- import { watch, existsSync as existsSync10 } from "node:fs";
5721
- import { join as join13 } from "node:path";
5924
+ import { watch, existsSync as existsSync11 } from "node:fs";
5925
+ import { join as join14 } from "node:path";
5722
5926
  var Broadcaster = class {
5723
5927
  listeners = /* @__PURE__ */ new Set();
5724
5928
  subscribe(fn) {
@@ -5736,7 +5940,7 @@ var Broadcaster = class {
5736
5940
  };
5737
5941
  var IGNORE = /(^|[\\/])(node_modules|dist|\.git)([\\/]|$)/;
5738
5942
  function watchSource(projectDir, onChange, debounceMs = 200) {
5739
- const dir = existsSync10(join13(projectDir, "src")) ? join13(projectDir, "src") : projectDir;
5943
+ const dir = existsSync11(join14(projectDir, "src")) ? join14(projectDir, "src") : projectDir;
5740
5944
  let timer;
5741
5945
  const watcher = watch(dir, { recursive: true }, (_event, file) => {
5742
5946
  const name = typeof file === "string" ? file : "";
@@ -5840,7 +6044,7 @@ var FrameBuffer = class {
5840
6044
  };
5841
6045
 
5842
6046
  // src/lanes.ts
5843
- import { renderMorphHtml, layoutIr as layoutIr2 } from "@intentius/pinhole";
6047
+ import { renderMorphHtml as renderMorphHtml2, layoutIr as layoutIr2 } from "@intentius/pinhole";
5844
6048
  function safeJson(value) {
5845
6049
  return JSON.stringify(value).replace(/</g, "\\u003c");
5846
6050
  }
@@ -5967,7 +6171,7 @@ function renderLanes(frames, summaries) {
5967
6171
  ir: f.ir,
5968
6172
  layout: layoutIr2(f.ir)
5969
6173
  }));
5970
- const doc = renderMorphHtml(views, { title: "Deployment lanes" });
6174
+ const doc = renderMorphHtml2(views, { title: "Deployment lanes" });
5971
6175
  const laneFrames = frames.map((f, i) => ({
5972
6176
  t: summaries[i].t,
5973
6177
  name: new Date(summaries[i].t).toISOString().slice(11, 19),
@@ -6014,13 +6218,13 @@ async function emulatorDown(projectDir) {
6014
6218
  }
6015
6219
 
6016
6220
  // src/demos.ts
6017
- import { readFileSync as readFileSync11, existsSync as existsSync11, cpSync } from "node:fs";
6018
- import { join as join14, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
6019
- import { spawn as spawn4, spawnSync } from "node:child_process";
6221
+ import { readFileSync as readFileSync12, existsSync as existsSync12, cpSync } from "node:fs";
6222
+ import { join as join15, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
6223
+ import { spawn as spawn5, spawnSync } from "node:child_process";
6020
6224
  function loadDemoRegistry(pkgRoot2) {
6021
6225
  let raw;
6022
6226
  try {
6023
- raw = JSON.parse(readFileSync11(join14(pkgRoot2, "demos.json"), "utf8"));
6227
+ raw = JSON.parse(readFileSync12(join15(pkgRoot2, "demos.json"), "utf8"));
6024
6228
  } catch {
6025
6229
  return [];
6026
6230
  }
@@ -6061,16 +6265,16 @@ function fetchesFromNetwork(entry) {
6061
6265
  }
6062
6266
  function demoTargetDir(entry, cwd = process.cwd()) {
6063
6267
  const legacy = resolve4(cwd, "behold-demo");
6064
- if (entry.name === "writes" && existsSync11(legacy)) return legacy;
6268
+ if (entry.name === "writes" && existsSync12(legacy)) return legacy;
6065
6269
  return resolve4(cwd, "behold-demos", entry.name);
6066
6270
  }
6067
6271
  async function loadDemo(entry, opts) {
6068
6272
  const { pkgRoot: pkgRoot2, target } = opts;
6069
6273
  const say = (line) => opts.log?.(`behold demo ${entry.name} \u2192 ${line}`);
6070
- if (!existsSync11(target)) {
6274
+ if (!existsSync12(target)) {
6071
6275
  if (entry.source === "bundled") {
6072
- const bundled = join14(pkgRoot2, entry.dir);
6073
- if (!existsSync11(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6276
+ const bundled = join15(pkgRoot2, entry.dir);
6277
+ if (!existsSync12(bundled)) return { ok: false, error: `this install has no bundled ${entry.dir}` };
6074
6278
  say(`copying to ${target} (it's yours \u2014 edit it)`);
6075
6279
  try {
6076
6280
  cpSync(bundled, target, {
@@ -6089,7 +6293,7 @@ async function loadDemo(entry, opts) {
6089
6293
  } else {
6090
6294
  say(`reusing ${target}`);
6091
6295
  }
6092
- if (existsSync11(join14(target, "package.json")) && !existsSync11(join14(target, "node_modules"))) {
6296
+ if (existsSync12(join15(target, "package.json")) && !existsSync12(join15(target, "node_modules"))) {
6093
6297
  say("npm install\u2026");
6094
6298
  if (await runStep("npm", ["install"], { cwd: target, shell: process.platform === "win32" }) !== 0) {
6095
6299
  return { ok: false, error: `npm install failed in ${target}` };
@@ -6101,19 +6305,19 @@ async function loadDemo(entry, opts) {
6101
6305
  return { ok: false, error: `setup failed (${entry.setup})` };
6102
6306
  }
6103
6307
  }
6104
- return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join14(target, d)) : [target] };
6308
+ return { ok: true, serveDirs: entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join15(target, d)) : [target] };
6105
6309
  }
6106
6310
  function runStep(cmd, args, opts = {}) {
6107
6311
  return new Promise((res) => {
6108
- const child = spawn4(cmd, args, { stdio: "inherit", cwd: opts.cwd, shell: opts.shell ?? false });
6312
+ const child = spawn5(cmd, args, { stdio: "inherit", cwd: opts.cwd, shell: opts.shell ?? false });
6109
6313
  child.on("error", () => res(-1));
6110
6314
  child.on("close", (code) => res(code ?? 1));
6111
6315
  });
6112
6316
  }
6113
6317
 
6114
6318
  // src/server.ts
6115
- var pkgRoot = join15(dirname4(fileURLToPath3(import.meta.url)), "..");
6116
- var webRoot = join15(pkgRoot, "web");
6319
+ var pkgRoot = join16(dirname4(fileURLToPath3(import.meta.url)), "..");
6320
+ var webRoot = join16(pkgRoot, "web");
6117
6321
  var execFileP = async (cmd, args) => (await promisify3(execFile3)(cmd, args, { encoding: "utf8", timeout: 1e4 })).stdout;
6118
6322
  function optsFromQuery(url, tierEnvVar, projectDir) {
6119
6323
  const q = url.searchParams;
@@ -6163,7 +6367,7 @@ function tierFailure(tier, message) {
6163
6367
  function beholdVersion() {
6164
6368
  try {
6165
6369
  const here = dirname4(fileURLToPath3(import.meta.url));
6166
- return JSON.parse(readFileSync12(join15(here, "..", "package.json"), "utf8")).version ?? "unknown";
6370
+ return JSON.parse(readFileSync13(join16(here, "..", "package.json"), "utf8")).version ?? "unknown";
6167
6371
  } catch {
6168
6372
  return "unknown";
6169
6373
  }
@@ -6208,7 +6412,7 @@ async function captureFrame(projectDir, env, frames, broadcaster) {
6208
6412
  }
6209
6413
  }
6210
6414
  function carveRoutes(app, reportPath, demo) {
6211
- const load = () => readCarveReport(reportPath, (p) => readFileSync12(p, "utf8"));
6415
+ const load = () => readCarveReport(reportPath, (p) => readFileSync13(p, "utf8"));
6212
6416
  const demoBlock = () => carveWriteBlock(demo);
6213
6417
  const demoInfo = () => {
6214
6418
  if (!demo) return null;
@@ -6224,6 +6428,9 @@ function carveRoutes(app, reportPath, demo) {
6224
6428
  fromLabel: relative3(demo.root, demo.from).split(sep4).join("/"),
6225
6429
  runnable: !block,
6226
6430
  ...block ? { reason: block } : {},
6431
+ // The live tier, when this boot armed it: the scratch endpoint the
6432
+ // stepper names, and the fact the plan button exists at all.
6433
+ ...demo.live ? { live: { endpoint: demo.live.endpoint, container: demo.live.container, applied: demo.live.applied } } : {},
6227
6434
  ...demo.degraded ? { degraded: demo.degraded } : {},
6228
6435
  buildCaveat: BUILD_CAVEAT
6229
6436
  };
@@ -6232,11 +6439,21 @@ function carveRoutes(app, reportPath, demo) {
6232
6439
  const parsed = load();
6233
6440
  return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422);
6234
6441
  });
6235
- app.get("/api/graph", (c) => {
6442
+ let appGraph;
6443
+ const appGraphOnce = () => {
6444
+ if (!demo) return Promise.resolve(null);
6445
+ appGraph ??= graphIr(demo.project).then((ir) => ir.nodes.length ? { ir, label: relative3(demo.root, demo.project).split(sep4).join("/") } : null).catch(() => null);
6446
+ return appGraph;
6447
+ };
6448
+ app.get("/api/graph", async (c) => {
6236
6449
  const parsed = load();
6237
6450
  if (!parsed.ok) return c.json(parsed.refusal, 422);
6238
- const ir = carveReportToIr(parsed.report);
6239
- const { svg } = renderBanded(ir);
6451
+ const tfIr = carveReportToIr(parsed.report);
6452
+ const appSide = await appGraphOnce();
6453
+ const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, {
6454
+ tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6455
+ appTitle: `${appSide.label} \u2014 chant`
6456
+ }) : { ...renderBanded(tfIr), ir: tfIr };
6240
6457
  return c.json({
6241
6458
  ir,
6242
6459
  svg,
@@ -6248,10 +6465,44 @@ function carveRoutes(app, reportPath, demo) {
6248
6465
  carve: true,
6249
6466
  // A demo whose own advisor run failed says so on the statusbar, not
6250
6467
  // only in the terminal the viewer isn't looking at.
6251
- note: carveNote(parsed.report, ir) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6468
+ note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : "")
6252
6469
  }
6253
6470
  });
6254
6471
  });
6472
+ app.get("/carve/morph", async (c) => {
6473
+ const parsed = load();
6474
+ if (!parsed.ok) return c.json(parsed.refusal, 422);
6475
+ const select = c.req.query("select") ?? "";
6476
+ const ranked = (parsed.report.resources ?? []).some((r) => r.address === select);
6477
+ if (!ranked) {
6478
+ return c.json(
6479
+ {
6480
+ error: `"${select}" is not a ranked address in this report`,
6481
+ code: "carve_bad_select",
6482
+ remedy: "pass ?select=<address> naming a resource the report ranks \u2014 GET /api/carve lists them"
6483
+ },
6484
+ 400
6485
+ );
6486
+ }
6487
+ const appSide = await appGraphOnce();
6488
+ if (!appSide) {
6489
+ return c.json(
6490
+ {
6491
+ error: "the morph needs the demo estate's chant project, and this server isn't serving a demo copy",
6492
+ code: "carve_no_demo",
6493
+ remedy: "run `behold demo carve` \u2014 the bundled estate has the chant box the card moves into"
6494
+ },
6495
+ 404
6496
+ );
6497
+ }
6498
+ const tfIr = carveReportToIr(parsed.report);
6499
+ const html = renderCarveMorph(tfIr, appSide.ir, [select], {
6500
+ tfTitle: `${relative3(demo.root, demo.from).split(sep4).join("/")} \u2014 terraform`,
6501
+ appTitle: `${appSide.label} \u2014 chant`,
6502
+ title: `carve \u2014 ${select}`
6503
+ });
6504
+ return c.html(html);
6505
+ });
6255
6506
  app.get("/api/project", (c) => {
6256
6507
  const parsed = load();
6257
6508
  return c.json({
@@ -6314,6 +6565,17 @@ function carveRoutes(app, reportPath, demo) {
6314
6565
  };
6315
6566
  app.post("/api/carve/emit", (c) => runStep2(c, runCarveEmit));
6316
6567
  app.post("/api/carve/bridge", (c) => runStep2(c, runCarveBridge));
6568
+ app.post("/api/carve/plan", async (c) => {
6569
+ const block = demoBlock();
6570
+ if (block) {
6571
+ return c.json({ error: block, code: "read-only", remedy: "The live plan runs inside a demo copy \u2014 `behold demo carve --live`." }, 403);
6572
+ }
6573
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) {
6574
+ return c.json({ error: "send application/json", code: "carve-action", remedy: "POST {} with content-type application/json" }, 415);
6575
+ }
6576
+ const result = await runCarvePlan(demo);
6577
+ return result.ok ? c.json(result) : c.json(result.refusal, 422);
6578
+ });
6317
6579
  app.get("/api/substrates", (c) => c.json({ substrates: [] }));
6318
6580
  app.get("/api/history", (c) => c.json({ commits: [] }));
6319
6581
  app.get("/api/resources", (c) => c.json({ byComponent: {} }));
@@ -6410,9 +6672,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6410
6672
  return c.json({ started: true, name, ran: label });
6411
6673
  });
6412
6674
  app.post("/api/local/reset", (c) => {
6413
- const down = join15(cfg.projectDir, "scripts/local/local-down.sh");
6414
- const up = join15(cfg.projectDir, "scripts/local/local-up.sh");
6415
- if (!existsSync12(down) || !existsSync12(up)) {
6675
+ const down = join16(cfg.projectDir, "scripts/local/local-down.sh");
6676
+ const up = join16(cfg.projectDir, "scripts/local/local-up.sh");
6677
+ if (!existsSync13(down) || !existsSync13(up)) {
6416
6678
  return c.json({ error: "no local-down.sh / local-up.sh in scripts/local \u2014 reset is only for local emulator projects" }, 400);
6417
6679
  }
6418
6680
  if (!runner.bringUp("reset local emulator", "bash", ["-c", "bash scripts/local/local-down.sh && bash scripts/local/local-up.sh"], cfg.projectDir)) {
@@ -6490,8 +6752,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6490
6752
  if (cfg.previewMode) return c.json({ error: "switching projects is locked in preview mode" }, 403);
6491
6753
  const body = await c.req.json().catch(() => ({}));
6492
6754
  const dir = typeof body.dir === "string" && body.dir.trim() ? resolve5(body.dir.trim()) : "";
6493
- if (!dir || !existsSync12(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6494
- if (!existsSync12(join15(dir, "chant.config.ts"))) {
6755
+ if (!dir || !existsSync13(dir)) return c.json({ error: `no such directory: ${dir || "(no dir given)"}` }, 400);
6756
+ if (!existsSync13(join16(dir, "chant.config.ts"))) {
6495
6757
  return c.json({ error: `${dir} doesn't look like a chant project \u2014 no chant.config.ts` }, 400);
6496
6758
  }
6497
6759
  switchServedProject([dir]);
@@ -6535,7 +6797,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6535
6797
  fetches: fetchesFromNetwork(e),
6536
6798
  ...e.repo ? { repo: e.repo } : {},
6537
6799
  target,
6538
- loaded: existsSync12(target),
6800
+ loaded: existsSync13(target),
6539
6801
  satisfiable: missing.length === 0,
6540
6802
  // #254: the carve walkthrough runs fine here — it just isn't a project
6541
6803
  // to switch INTO. Carve mode claims `/api/graph` and `/api/project` at
@@ -6579,7 +6841,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6579
6841
  const loaded = await loadDemo(entry, { pkgRoot, target, log: (line) => process.stdout.write(line + "\n") });
6580
6842
  if (!loaded.ok) return c.json({ error: loaded.error }, 500);
6581
6843
  const dirs = loaded.serveDirs;
6582
- if (!existsSync12(join15(dirs[0], "chant.config.ts"))) {
6844
+ if (!existsSync13(join16(dirs[0], "chant.config.ts"))) {
6583
6845
  return c.json({ error: `${entry.name} loaded to ${target} but ${dirs[0]} has no chant.config.ts` }, 500);
6584
6846
  }
6585
6847
  if (entry.serve.local) {
@@ -6785,7 +7047,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
6785
7047
  srcCompositeEdgesAttached = 0;
6786
7048
  }
6787
7049
  }
6788
- if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync12(join15(cfg.projectDir, "chant.config.ts"))) {
7050
+ if (!multi && !components && !logical && !opts.lens && ir.nodes.length === 0 && !existsSync13(join16(cfg.projectDir, "chant.config.ts"))) {
6789
7051
  return c.json(noProjectError(cfg.projectDir), 404);
6790
7052
  }
6791
7053
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
@@ -7125,7 +7387,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
7125
7387
  });
7126
7388
  const rel = relative3(process.cwd(), webRoot) || ".";
7127
7389
  app.use("/*", serveStatic({ root: rel }));
7128
- app.get("/", serveStatic({ path: join15(rel, "index.html") }));
7390
+ app.get("/", serveStatic({ path: join16(rel, "index.html") }));
7129
7391
  return app;
7130
7392
  }
7131
7393
  async function bootLocalEmulators(dir, who) {
@@ -7154,7 +7416,7 @@ async function bootLocalEmulators(dir, who) {
7154
7416
  async function startServer(cfg) {
7155
7417
  if (cfg.local) cfg.emulators = await bootLocalEmulators(cfg.projectDir, "behold serve --local");
7156
7418
  for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
7157
- if (existsSync12(join15(dir, "chant.config.ts"))) addRecent(dir);
7419
+ if (existsSync13(join16(dir, "chant.config.ts"))) addRecent(dir);
7158
7420
  }
7159
7421
  const broadcaster = new Broadcaster();
7160
7422
  const frames = new FrameBuffer();
@@ -7165,10 +7427,10 @@ async function startServer(cfg) {
7165
7427
  });
7166
7428
  const app = createApp(cfg, broadcaster, frames, runner);
7167
7429
  const autoSync = cfg.autoSync ?? "off";
7168
- const capture = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
7430
+ const capture2 = () => captureFrame(cfg.projectDir, cfg.env, frames, broadcaster);
7169
7431
  const onEstateChange = () => {
7170
7432
  broadcaster.emit("changed");
7171
- void capture();
7433
+ void capture2();
7172
7434
  };
7173
7435
  const onPollDrift = (movedLexicons) => {
7174
7436
  onEstateChange();
@@ -7215,18 +7477,24 @@ async function startServer(cfg) {
7215
7477
  };
7216
7478
  process.stdout.write(` switched \u2192 ${dir}
7217
7479
  `);
7218
- void capture();
7480
+ void capture2();
7219
7481
  };
7220
- if (!carve) void capture();
7482
+ if (!carve) void capture2();
7221
7483
  let shuttingDown = false;
7222
7484
  const shutdown = () => {
7223
7485
  if (shuttingDown) return;
7224
7486
  shuttingDown = true;
7225
7487
  stopWatch();
7226
7488
  stopPoll();
7227
- const done = cfg.local && cfg.emulators && cfg.emulators.length ? emulatorDown(cfg.projectDir).catch((err) => process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}
7228
- `)) : Promise.resolve();
7229
- void done.finally(() => process.exit(0));
7489
+ const downs = [];
7490
+ if (cfg.local && cfg.emulators && cfg.emulators.length) {
7491
+ downs.push(
7492
+ emulatorDown(cfg.projectDir).catch((err) => process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}
7493
+ `))
7494
+ );
7495
+ }
7496
+ if (cfg.carveDemo?.live) downs.push(teardownScratchFloci().catch(() => void 0));
7497
+ void Promise.all(downs).finally(() => process.exit(0));
7230
7498
  };
7231
7499
  process.on("SIGINT", shutdown);
7232
7500
  process.on("SIGTERM", shutdown);
@@ -7238,7 +7506,8 @@ async function startServer(cfg) {
7238
7506
  green = carve now, amber = boundary work, grey = leave in Terraform.
7239
7507
  ` + (cfg.carveDemo ? ` walkthrough: the panel's Carve tab \u2014 advise \u2192 pick \u2192 emit \u2192 bridge \u2192 handoff \u2192 done.
7240
7508
  Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.
7241
- ` + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}
7509
+ ` + (cfg.carveDemo.live ? ` live: scratch Floci at ${cfg.carveDemo.live.endpoint} \u2014 deleted on Ctrl-C.
7510
+ ` : "") + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}
7242
7511
  ` : "") : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.
7243
7512
  `)
7244
7513
  );
@@ -7275,8 +7544,8 @@ async function startServer(cfg) {
7275
7544
  }
7276
7545
 
7277
7546
  // src/export.ts
7278
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, copyFileSync, readFileSync as readFileSync13, readdirSync as readdirSync5 } from "node:fs";
7279
- import { join as join16, dirname as dirname5, basename } from "node:path";
7547
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, copyFileSync, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "node:fs";
7548
+ import { join as join17, dirname as dirname5, basename } from "node:path";
7280
7549
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7281
7550
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
7282
7551
  function canonicalKey(path, params) {
@@ -7324,7 +7593,7 @@ function captureKeys(axes) {
7324
7593
  return [...keys];
7325
7594
  }
7326
7595
  function webDir() {
7327
- return join16(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
7596
+ return join17(dirname5(fileURLToPath4(import.meta.url)), "..", "web");
7328
7597
  }
7329
7598
  function workerName(project, override) {
7330
7599
  const raw = override ?? `behold-${basename(project)}`;
@@ -7335,7 +7604,7 @@ async function runExport(cfg, outDir, opts = {}) {
7335
7604
  const app = createApp({ ...cfg, layoutWrites: false });
7336
7605
  const proj = await (await app.request("/api/project")).json();
7337
7606
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
7338
- const snapDir = join16(outDir, "snapshots");
7607
+ const snapDir = join17(outDir, "snapshots");
7339
7608
  mkdirSync4(snapDir, { recursive: true });
7340
7609
  const keyToFile = {};
7341
7610
  let ok = 0;
@@ -7344,7 +7613,7 @@ async function runExport(cfg, outDir, opts = {}) {
7344
7613
  const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`);
7345
7614
  const body = await res.text();
7346
7615
  const file = slug2(key);
7347
- writeFileSync3(join16(snapDir, file), body);
7616
+ writeFileSync4(join17(snapDir, file), body);
7348
7617
  keyToFile[key] = `snapshots/${file}`;
7349
7618
  if (res.ok) ok++;
7350
7619
  else failed++;
@@ -7356,21 +7625,21 @@ async function runExport(cfg, outDir, opts = {}) {
7356
7625
  axes,
7357
7626
  keyToFile
7358
7627
  };
7359
- writeFileSync3(join16(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7360
- const html = readFileSync13(join16(webDir(), "index.html"), "utf8").replace(
7628
+ writeFileSync4(join17(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
7629
+ const html = readFileSync14(join17(webDir(), "index.html"), "utf8").replace(
7361
7630
  /<\/head>/i,
7362
7631
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
7363
7632
  </head>`
7364
7633
  );
7365
- writeFileSync3(join16(outDir, "index.html"), html);
7634
+ writeFileSync4(join17(outDir, "index.html"), html);
7366
7635
  for (const f of readdirSync5(webDir())) {
7367
7636
  if (f === "index.html") continue;
7368
- copyFileSync(join16(webDir(), f), join16(outDir, f));
7637
+ copyFileSync(join17(webDir(), f), join17(outDir, f));
7369
7638
  }
7370
- writeFileSync3(join16(outDir, "README.md"), BUNDLE_README);
7639
+ writeFileSync4(join17(outDir, "README.md"), BUNDLE_README);
7371
7640
  const name = workerName(cfg.projectDir, opts.name);
7372
- writeFileSync3(
7373
- join16(outDir, "wrangler.jsonc"),
7641
+ writeFileSync4(
7642
+ join17(outDir, "wrangler.jsonc"),
7374
7643
  JSON.stringify(
7375
7644
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
7376
7645
  null,
@@ -7637,6 +7906,7 @@ var USAGE = `behold \u2014 a live control plane on chant (read-only core)
7637
7906
 
7638
7907
  Usage:
7639
7908
  behold demo [name] [target-dir] [--port <n>] [--list]
7909
+ behold demo carve [--live] [--port <n>]
7640
7910
  behold doctor [project-dir] [--json]
7641
7911
  behold preview [project-dir] [--port <n>] [--emulator]
7642
7912
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
@@ -7669,7 +7939,10 @@ Usage:
7669
7939
  up on a throwaway k3d cluster instead. \`behold demo carve\` is the
7670
7940
  odd one out: no cluster, no Docker, no cloud \u2014 a half-migrated
7671
7941
  Terraform/chant estate plus the six-step carve walkthrough on the
7672
- panel's Carve tab. Needs Docker (and per-demo tools --list names).
7942
+ panel's Carve tab. Its \`--live\` tier flips that: docker + terraform
7943
+ on PATH, a scratch Floci booted and deleted on exit, the starred
7944
+ resources REALLY applied, and a real \`terraform plan\` button at
7945
+ Handoff. Needs Docker (and per-demo tools --list names).
7673
7946
  Loaded demos land in the panel's recents, so switching between them
7674
7947
  is the Scope tab \u2014 which lists this whole catalog too (#268), one
7675
7948
  click from any served project.
@@ -7836,7 +8109,7 @@ function warnIfNotChantProject(dir) {
7836
8109
  if (shape.kind === "estate") {
7837
8110
  process.stderr.write(
7838
8111
  `behold: warning \u2014 ${dir} is an estate root, not a chant project itself.
7839
- Serve its members composed: behold serve ${shape.members.map((m) => join17(dir, m)).join(" ")}
8112
+ Serve its members composed: behold serve ${shape.members.map((m) => join18(dir, m)).join(" ")}
7840
8113
  `
7841
8114
  );
7842
8115
  return;
@@ -7872,7 +8145,7 @@ async function runCarve(rest) {
7872
8145
  process.exit(2);
7873
8146
  }
7874
8147
  const reportPath = resolve7(fileArg);
7875
- const parsed = readCarveReport(reportPath, (p) => readFileSync14(p, "utf8"));
8148
+ const parsed = readCarveReport(reportPath, (p) => readFileSync15(p, "utf8"));
7876
8149
  if (!parsed.ok) {
7877
8150
  process.stderr.write(`behold carve: ${parsed.refusal.error}
7878
8151
  ${parsed.refusal.remedy}
@@ -7899,7 +8172,7 @@ async function runDoctor(rest) {
7899
8172
  }
7900
8173
  }
7901
8174
  const dir = dirArg ?? ".";
7902
- if (!existsSync13(resolve7(dir))) {
8175
+ if (!existsSync14(resolve7(dir))) {
7903
8176
  process.stderr.write(`behold doctor: no such directory: ${resolve7(dir)}
7904
8177
  `);
7905
8178
  process.exit(2);
@@ -7909,14 +8182,16 @@ async function runDoctor(rest) {
7909
8182
  if (!report.ok) process.exitCode = 1;
7910
8183
  }
7911
8184
  async function runDemo(rest) {
7912
- const pkgRoot2 = join17(dirname6(fileURLToPath5(import.meta.url)), "..");
8185
+ const pkgRoot2 = join18(dirname6(fileURLToPath5(import.meta.url)), "..");
7913
8186
  const registry = loadDemoRegistry(pkgRoot2);
7914
8187
  let port = 4600;
8188
+ let live = false;
7915
8189
  let name;
7916
8190
  let dirArg;
7917
8191
  for (let i = 0; i < rest.length; i++) {
7918
8192
  const a = rest[i];
7919
8193
  if (a === "--port") port = Number(rest[++i]);
8194
+ else if (a === "--live") live = true;
7920
8195
  else if (a === "--list") {
7921
8196
  if (!registry.length) {
7922
8197
  process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n");
@@ -7969,9 +8244,14 @@ async function runDemo(rest) {
7969
8244
  process.exit(1);
7970
8245
  }
7971
8246
  if (entry.serve.carve) {
7972
- await serveCarveDemo(target, entry.serve.carve, port);
8247
+ await serveCarveDemo(target, entry.serve.carve, port, live);
7973
8248
  return;
7974
8249
  }
8250
+ if (live) {
8251
+ process.stderr.write(`behold demo ${entry.name}: --live is the carve demo's tier \u2014 only \`behold demo carve --live\` takes it.
8252
+ `);
8253
+ process.exit(2);
8254
+ }
7975
8255
  process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
7976
8256
  `);
7977
8257
  const serveArgs = ["serve", ...loaded.serveDirs, "--port", String(port)];
@@ -7981,18 +8261,53 @@ async function runDemo(rest) {
7981
8261
  }
7982
8262
  function spawnStep(cmd, args, cwd) {
7983
8263
  return new Promise((res) => {
7984
- const child = spawn5(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" });
8264
+ const child = spawn6(cmd, args, { stdio: "inherit", cwd, shell: process.platform === "win32" });
7985
8265
  child.on("error", () => res(-1));
7986
8266
  child.on("close", (code) => res(code ?? 1));
7987
8267
  });
7988
8268
  }
7989
- async function serveCarveDemo(target, carve, port) {
8269
+ async function serveCarveDemo(target, carve, port, live = false) {
7990
8270
  const at = (rel) => resolve7(target, rel);
7991
8271
  const project = at(carve.project);
7992
8272
  const from = at(carve.from);
7993
8273
  const state = carve.state ? at(carve.state) : void 0;
7994
8274
  const committed = at(carve.report);
7995
- if (existsSync13(join17(project, "package.json")) && !existsSync13(join17(project, "node_modules"))) {
8275
+ let liveInfo;
8276
+ if (live) {
8277
+ for (const bin of ["docker", "terraform"]) {
8278
+ const probe2 = spawnSync2(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
8279
+ if (probe2.status !== 0) {
8280
+ process.stderr.write(`behold demo carve --live: needs ${bin} on PATH (the offline tier doesn't \u2014 drop --live).
8281
+ `);
8282
+ process.exit(2);
8283
+ }
8284
+ }
8285
+ process.stdout.write(`behold demo carve --live \u2192 scratch Floci (${LIVE_CONTAINER}, :${LIVE_PORT}, deleted on exit)\u2026
8286
+ `);
8287
+ const bootErr = await bootScratchFloci();
8288
+ if (bootErr) {
8289
+ process.stderr.write(`behold demo carve --live: ${bootErr}
8290
+ `);
8291
+ process.exit(1);
8292
+ }
8293
+ process.stdout.write("behold demo carve --live \u2192 terraform init + apply (the starred resources, into the scratch Floci)\u2026\n");
8294
+ if (state) rmSync2(state, { force: true });
8295
+ const applyErr = await applyIntoFloci(from, spawnStep);
8296
+ if (applyErr) {
8297
+ await teardownScratchFloci();
8298
+ process.stderr.write(`behold demo carve --live: ${applyErr}
8299
+ `);
8300
+ process.exit(1);
8301
+ }
8302
+ liveInfo = {
8303
+ container: LIVE_CONTAINER,
8304
+ port: LIVE_PORT,
8305
+ endpoint: `http://localhost:${LIVE_PORT}`,
8306
+ applied: LIVE_TARGETS
8307
+ };
8308
+ process.stdout.write("behold demo carve --live \u2192 the tfstate is real now; the advisor reads what terraform wrote.\n");
8309
+ }
8310
+ if (existsSync14(join18(project, "package.json")) && !existsSync14(join18(project, "node_modules"))) {
7996
8311
  process.stdout.write(`behold demo carve \u2192 npm install in ${carve.project}/ (the chant this walkthrough shells)\u2026
7997
8312
  `);
7998
8313
  const code = await spawnStep("npm", ["install"], project);
@@ -8003,7 +8318,7 @@ async function serveCarveDemo(target, carve, port) {
8003
8318
  }
8004
8319
  }
8005
8320
  let degraded;
8006
- if (!existsSync13(join17(target, "node_modules", "@cdktf", "hcl2json"))) {
8321
+ if (!existsSync14(join18(target, "node_modules", "@cdktf", "hcl2json"))) {
8007
8322
  process.stdout.write("behold demo carve \u2192 npm install @cdktf/hcl2json (chant's HCL parser, ~2MB, once)\u2026\n");
8008
8323
  const code = await spawnStep("npm", ["install", "--no-save", "--no-package-lock", "@cdktf/hcl2json"], target);
8009
8324
  if (code !== 0) degraded = "couldn't install @cdktf/hcl2json (chant's HCL parser) \u2014 no network?";
@@ -8019,7 +8334,7 @@ async function serveCarveDemo(target, carve, port) {
8019
8334
  );
8020
8335
  if (code !== 0) degraded = `chant carve advise exited ${code}`;
8021
8336
  }
8022
- const serving = !degraded && existsSync13(report) ? report : committed;
8337
+ const serving = !degraded && existsSync14(report) ? report : committed;
8023
8338
  if (degraded) {
8024
8339
  process.stderr.write(
8025
8340
  `behold demo carve: ${degraded}
@@ -8027,7 +8342,7 @@ async function serveCarveDemo(target, carve, port) {
8027
8342
  `
8028
8343
  );
8029
8344
  }
8030
- if (!existsSync13(serving)) {
8345
+ if (!existsSync14(serving)) {
8031
8346
  process.stderr.write(`behold demo carve: no carve report at ${serving}
8032
8347
  `);
8033
8348
  process.exit(2);
@@ -8044,6 +8359,7 @@ async function serveCarveDemo(target, carve, port) {
8044
8359
  ...state ? { state } : {},
8045
8360
  project,
8046
8361
  out: at(carve.out),
8362
+ ...liveInfo ? { live: liveInfo } : {},
8047
8363
  ...degraded ? { degraded: `${degraded} \u2014 showing the committed report shipped with the demo.` } : {}
8048
8364
  },
8049
8365
  port
@@ -8072,7 +8388,7 @@ async function runPreview(rest) {
8072
8388
  process.exit(2);
8073
8389
  }
8074
8390
  const projectDir = resolve7(dirArg ?? process.cwd());
8075
- if (!existsSync13(projectDir)) {
8391
+ if (!existsSync14(projectDir)) {
8076
8392
  process.stderr.write(`behold preview: project not found at ${projectDir}
8077
8393
  `);
8078
8394
  process.exit(2);
@@ -8111,7 +8427,7 @@ async function runExportCmd(rest) {
8111
8427
  injectEmulatorEnv(env);
8112
8428
  env ??= "local";
8113
8429
  }
8114
- if (!existsSync13(projectDir)) {
8430
+ if (!existsSync14(projectDir)) {
8115
8431
  process.stderr.write(`behold export: project not found at ${projectDir}
8116
8432
  `);
8117
8433
  process.exit(2);
@@ -170,14 +170,21 @@ no AWS account and no network. The only dependency beyond this directory is
170
170
  `@cdktf/hcl2json`, which chant lazy-loads and names in its error if absent. This
171
171
  tier guarantees the first thirty seconds of the video.
172
172
 
173
- **`--live` is the full video.** Boot a scratch Floci, arm
174
- `legacy-tf/floci-override.tf.disabled` (see its header), and `terraform apply`
175
- the estate into it, so the state is one terraform really wrote. Then the observe
176
- beats become footage rather than caption: after Emit, `chant lifecycle diff
177
- --live` reads the bucket out of Floci, clean, while Terraform still owns it; the
178
- handoff runs a real `terraform plan` showing no destroy; and behold's overlay
179
- flips the bucket green afterwards. The line it exists for is "Terraform forgot
180
- it, chant adopted it, and it never blinked."
173
+ **`--live` is the full video.** `behold demo carve --live` boots a scratch
174
+ Floci (`behold-carve-floci`, its own port, deleted on exit), arms
175
+ `legacy-tf/floci-override.tf.disabled` (see its header) into the demo copy, and
176
+ `terraform apply -target`s the starred resources into it so the tfstate the
177
+ advisor reads is one terraform really wrote. At Handoff the stepper gains a
178
+ read-only **terraform plan** button: before `state rm` it shows a no-op, after
179
+ it the carved bucket is simply gone from Terraform's world `0 to destroy`.
180
+ The line the tier exists for is "Terraform forgot it, chant adopted it, and it
181
+ never blinked."
182
+
183
+ One beat stays caption rather than footage for now: `chant lifecycle diff
184
+ --live` reading the bucket clean while Terraform still owns it. chant's AWS
185
+ observe is CFN-stack-scoped by logical id, so a Terraform-owned resource reads
186
+ confirmed-missing regardless of its existence — chant#1647 tracks the
187
+ physical-identity read path that unlocks it.
181
188
 
182
189
  The live tier needs `docker` and `terraform` on PATH, boots its own throwaway
183
190
  Floci and deletes it after, and never touches an existing `floci*` container.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "@hono/node-server": "^2.0.5",
38
38
  "@intentius/chant": "^0.38.0",
39
39
  "@intentius/chant-k8s-client": "^0.44.5",
40
- "@intentius/pinhole": "^0.3.3",
40
+ "@intentius/pinhole": "^0.3.4",
41
41
  "hono": "^4.12.34"
42
42
  },
43
43
  "devDependencies": {
package/web/app.js CHANGED
@@ -1233,6 +1233,32 @@ const carveActions = {
1233
1233
  },
1234
1234
  runEmit: () => runCarveStep("emit"),
1235
1235
  runBridge: () => runCarveStep("bridge"),
1236
+ async runPlan() {
1237
+ if (carveState.busy) return;
1238
+ carveState.busy = "plan";
1239
+ carveState.error = null;
1240
+ renderPanelCarve();
1241
+ try {
1242
+ const res = await fetch("/api/carve/plan", {
1243
+ method: "POST",
1244
+ headers: { "content-type": "application/json" },
1245
+ body: "{}",
1246
+ });
1247
+ const body = await res.json().catch(() => ({ error: "plan returned an unreadable body", remedy: "" }));
1248
+ if (!res.ok || body.error) {
1249
+ carveState.error = { step: "handoff", ...body };
1250
+ showToast(`✗ terraform plan: ${body.error || res.status}`, false);
1251
+ } else {
1252
+ carveState.plan = body;
1253
+ showToast(body.noDestroy ? "✓ plan: nothing to destroy" : "✗ plan wants to destroy something", body.noDestroy);
1254
+ }
1255
+ } catch (err) {
1256
+ carveState.error = { step: "handoff", error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
1257
+ } finally {
1258
+ carveState.busy = null;
1259
+ renderPanelCarve();
1260
+ }
1261
+ },
1236
1262
  copy(text, el) {
1237
1263
  const done = () => {
1238
1264
  el.dataset.copied = "1";
@@ -63,7 +63,10 @@ export function initialCarveState() {
63
63
  bridge: null,
64
64
  /** Set when the user says they ran the handoff commands. */
65
65
  handoff: false,
66
- /** Which step is mid-run ("emit" | "bridge" | null) the dial's progress. */
66
+ /** POST /api/carve/plan's answer (live tier only). Estate-wide, so a new
67
+ * pick keeps it — the plan is about the armed Terraform, not the pick. */
68
+ plan: null,
69
+ /** Which step is mid-run ("emit" | "bridge" | "plan" | null). */
67
70
  busy: null,
68
71
  /** The last structured refusal, keyed by step id. */
69
72
  error: null,
@@ -378,7 +381,7 @@ export function renderCarvePanel(host, state, ctx, actions) {
378
381
  pick: () => renderPick(body, state, ctx, actions),
379
382
  emit: () => renderEmit(body, state, ctx, actions),
380
383
  bridge: () => renderBridge(body, state, ctx, actions),
381
- handoff: () => renderHandoff(body, state, actions),
384
+ handoff: () => renderHandoff(body, state, ctx, actions),
382
385
  done: () => renderDone(body, state, ctx, actions),
383
386
  };
384
387
  renderers[step.id]();
@@ -426,6 +429,17 @@ function renderAdvise(body, ctx) {
426
429
  `Terraform read from ${ctx.demo.fromLabel}/ in your demo copy. Emit and bridge write only into ${ctx.demo.outLabel}/.`,
427
430
  ),
428
431
  );
432
+ if (ctx.demo.live) {
433
+ body.appendChild(
434
+ el(
435
+ "p",
436
+ "panel-muted carve-live-note",
437
+ `Live tier: the starred resources are REALLY applied into a scratch Floci (${ctx.demo.live.endpoint}, ` +
438
+ "deleted when this server exits) and the tfstate the advisor read was written by terraform, not shipped. " +
439
+ "The observe beat — chant reading the bucket live while Terraform still owns it — waits on chant#1647.",
440
+ ),
441
+ );
442
+ }
429
443
  }
430
444
  }
431
445
 
@@ -544,12 +558,38 @@ function renderBridge(body, state, ctx, actions) {
544
558
  for (const a of b.proposals || []) body.appendChild(artifactBlock(a, actions));
545
559
  }
546
560
 
547
- function renderHandoff(body, state, actions) {
561
+ function renderHandoff(body, state, ctx, actions) {
548
562
  const runbook = state.bridge && state.bridge.runbook;
549
563
  if (!runbook) {
550
564
  body.appendChild(el("p", "panel-muted", blockedReason(state, "handoff") || "no runbook yet"));
551
565
  return;
552
566
  }
567
+
568
+ // The live tier's read-only beat (#254): a REAL `terraform plan`. Before the
569
+ // operator pastes `state rm` it shows a no-op; after, the carved resource is
570
+ // simply absent from Terraform's world — not destroyed. That verdict line is
571
+ // the reason the tier exists.
572
+ if (ctx.demo && ctx.demo.live) {
573
+ const planBtn = el("button", "act carve-plan", state.busy === "plan" ? "planning…" : "▶ terraform plan (read-only)");
574
+ planBtn.disabled = !!state.busy;
575
+ planBtn.title = "Runs terraform plan in the demo copy against the scratch Floci. Reads everything, changes nothing.";
576
+ planBtn.addEventListener("click", actions.runPlan);
577
+ body.appendChild(planBtn);
578
+ if (state.plan) {
579
+ const verdict = el("div", "carve-plan-verdict");
580
+ verdict.appendChild(el("code", null, state.plan.planLine));
581
+ verdict.appendChild(
582
+ el(
583
+ "p",
584
+ "panel-muted",
585
+ state.plan.noDestroy
586
+ ? "0 to destroy — the resource never blinked."
587
+ : "The plan wants to destroy something. Stop and read it before touching the estate.",
588
+ ),
589
+ );
590
+ body.appendChild(verdict);
591
+ }
592
+ }
553
593
  // The one place the walkthrough refuses to be a button, stated out loud.
554
594
  const why = el("div", "carve-refusal carve-human");
555
595
  why.appendChild(el("div", "carve-refusal-title", "These two are not buttons, on purpose."));
@@ -605,13 +645,20 @@ function renderDone(body, state, ctx, actions) {
605
645
  if (!state.handoff) {
606
646
  body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
607
647
  }
648
+ if (state.pick) {
649
+ const watch = el("a", "act carve-morph-link", "▶ watch it move");
650
+ watch.href = "/carve/morph?select=" + encodeURIComponent(state.pick.node.id);
651
+ watch.target = "_blank";
652
+ watch.rel = "noopener";
653
+ watch.title = "The estate frame, morphing: the card slides out of the Terraform box and into the chant project box beside last month's carves.";
654
+ body.appendChild(watch);
655
+ }
608
656
  body.appendChild(
609
657
  el(
610
658
  "p",
611
659
  "panel-muted carve-honesty",
612
- "Deferred to the follow-up: the card doesn't yet SLIDE out of the Terraform box and into the chant project " +
613
- "beside last month's carves — it is marked in place. The Floci `--live` tier (a real terraform apply, a real " +
614
- "plan showing no destroy) is the other half of that follow-up.",
660
+ "Deferred to the follow-up: the Floci `--live` tier a real terraform apply into a scratch emulator, " +
661
+ "a real plan showing no destroy.",
615
662
  ),
616
663
  );
617
664
  const again = el("button", "act", "↺ start over");