@intentius/behold 0.21.0 → 0.23.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.
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.95.0"
7
+ "version": "0.102.0"
8
8
  }
9
9
  ],
10
10
  "members": [
@@ -90,10 +90,20 @@ install them, so that every user who serves an ordinary chant project does not
90
90
  carry an HCL parser they will never run:
91
91
 
92
92
  ```sh
93
- npm install --no-save @intentius/chant-lexicon-terraform @cdktn/hcl2json
93
+ npm install --no-save @intentius/chant-lexicon-terraform@^0.102.0 @cdktn/hcl2json@^0.24.0
94
94
  ```
95
95
 
96
- Without them the demo refuses with that exact line and says where it looked.
96
+ Without them the demo refuses before it serves, naming both packages, the
97
+ ranges behold declares, and where it looked.
98
+
99
+ The estate is a declared chant workspace, and chant reads a member's kind from
100
+ the packages `chant.workspace.json` pins, looked up from the workspace's own
101
+ `node_modules` at exactly the pinned version. A demo copy has no
102
+ `node_modules`, so `behold demo` links the lexicon behold resolved into the
103
+ copy and sets the copy's pin to that version (#484). It says so as it loads:
104
+ `linked @intentius/chant-lexicon-terraform 0.102.0 into the copy`. Serving
105
+ your own declared workspace is different: install the pinned lexicon in its
106
+ root, the way `chant workspace check` asks.
97
107
 
98
108
  ## Not the carve demo
99
109
 
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.95.0"
7
+ "version": "0.102.0"
8
8
  }
9
9
  ],
10
10
  "members": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "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.",
@@ -35,19 +35,20 @@
35
35
  "smoke:ui": "tsx smoke/ui-smoke.mjs",
36
36
  "tsc": "tsc --noEmit",
37
37
  "test": "vitest run",
38
- "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --external:hono --external:@hono/node-server --external:@intentius/pinhole --external:@kubernetes/client-node --outfile=dist/cli.js && chmod +x dist/cli.js",
38
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --external:hono --external:@hono/node-server --external:@intentius/pinhole --external:@kubernetes/client-node --external:ajv --outfile=dist/cli.js && chmod +x dist/cli.js",
39
39
  "prepare": "npm run build",
40
40
  "prepublishOnly": "npm run build"
41
41
  },
42
42
  "dependencies": {
43
43
  "@hono/node-server": "^2.0.5",
44
- "@intentius/chant": "^0.95.0",
45
- "@intentius/chant-k8s-client": "^0.95.0",
44
+ "@intentius/chant": "^0.102.0",
45
+ "@intentius/chant-k8s-client": "^0.102.0",
46
46
  "@intentius/pinhole": "^0.3.8",
47
+ "ajv": "^8.20.0",
47
48
  "hono": "^4.12.34"
48
49
  },
49
50
  "peerDependencies": {
50
- "@intentius/chant-lexicon-terraform": "^0.95.0",
51
+ "@intentius/chant-lexicon-terraform": "^0.102.0",
51
52
  "@cdktn/hcl2json": "^0.24.0"
52
53
  },
53
54
  "peerDependenciesMeta": {
package/web/app.js CHANGED
@@ -12,6 +12,8 @@ import { createRefreshQueue } from "./refresh-queue.js";
12
12
  import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
13
13
  import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
14
  import { gateCards } from "./workspace-gates.js";
15
+ import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
16
+ import { seconds, whyQuery, whyView } from "./why.js";
15
17
  import { readCostLine } from "./read-cost.js";
16
18
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
17
19
  import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
@@ -579,6 +581,194 @@ function renderDriftSection(section, r) {
579
581
  pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
580
582
  }
581
583
 
584
+ // ---------------------------------------------------------------------------
585
+ // #471: the "why" of a member or a card in a declared workspace.
586
+ //
587
+ // Read on demand: `chant workspace graph --intent` walks git history and takes
588
+ // seconds on a small workspace and tens of seconds on a large one, so nothing
589
+ // asks for it until someone clicks. While it reads, the section says so with
590
+ // the seconds counting and a Stop button; the rest of the page goes on as
591
+ // before. Picking something else aborts the request, and the server's read
592
+ // scheduler stops chant when nobody is waiting for it any more. In embed mode
593
+ // the section is drawn the same; clicks on the graph still go to the host.
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /** The member a card or a box belongs to, when the served workspace declares it. */
597
+ function declaredMember(name) {
598
+ const ws = projectInfo && projectInfo.workspace;
599
+ return (ws && ws.members.find((m) => m.name === name)) || null;
600
+ }
601
+
602
+ /** Answers already read this session, by query. A source change clears them. */
603
+ const whyAnswers = new Map();
604
+ /** The read in flight, so a new pick can stop it. */
605
+ let whyInFlight = null;
606
+
607
+ function stopWhyRead() {
608
+ if (whyInFlight) whyInFlight.abort();
609
+ whyInFlight = null;
610
+ }
611
+
612
+ function renderWhySection(panel, ask) {
613
+ stopWhyRead();
614
+ if (staticMode) return;
615
+ const memberName = ask.member || nodeMember(ask.node || "");
616
+ if (!declaredMember(memberName)) return;
617
+ const query = whyQuery(ask);
618
+ const forId = panel.dataset.node;
619
+ const h = document.createElement("h3");
620
+ h.textContent = "why";
621
+ const box = document.createElement("div");
622
+ box.className = "why";
623
+ box.dataset.why = query;
624
+ panel.append(h, box);
625
+
626
+ const button = (label, title, onClick) => {
627
+ const b = document.createElement("button");
628
+ b.type = "button";
629
+ b.className = "why-read";
630
+ b.textContent = label;
631
+ b.title = title;
632
+ b.addEventListener("click", onClick);
633
+ return b;
634
+ };
635
+ const idle = (note) => {
636
+ box.replaceChildren();
637
+ const p = document.createElement("p");
638
+ p.className = "queried";
639
+ p.textContent = note || `The decisions that cover ${ask.node ? "this card" : `member ${memberName}`}, and the runs and commits behind it, from chant workspace graph --intent. Reading walks the repository's history; on a large one it takes a while.`;
640
+ box.append(p, button("Read why", "Ask chant now. Nothing is read until you click.", read));
641
+ };
642
+ const read = async () => {
643
+ stopWhyRead();
644
+ const controller = new AbortController();
645
+ whyInFlight = controller;
646
+ const started = Date.now();
647
+ box.replaceChildren();
648
+ const status = document.createElement("p");
649
+ status.className = "queried why-reading";
650
+ const tick = () => (status.textContent = `reading… ${seconds(Date.now() - started)}`);
651
+ tick();
652
+ const timer = setInterval(tick, 250);
653
+ box.append(status, button("Stop", "Stop the read; chant is stopped too when nothing else waits for it.", () => {
654
+ stopWhyRead();
655
+ idle("Stopped. Nothing was read.");
656
+ }));
657
+ let answer;
658
+ try {
659
+ answer = await fetch(query, { signal: controller.signal }).then((r) => r.json());
660
+ } catch (e) {
661
+ clearInterval(timer);
662
+ if (controller.signal.aborted) return;
663
+ answer = { error: `The why read failed: ${e.message}`, remedy: "" };
664
+ }
665
+ clearInterval(timer);
666
+ if (whyInFlight === controller) whyInFlight = null;
667
+ if (panel.dataset.node !== forId || !box.isConnected) return;
668
+ if (!answer.refusal && !answer.error) whyAnswers.set(query, answer);
669
+ paintWhy(box, answer, read);
670
+ };
671
+ const known = whyAnswers.get(query);
672
+ if (known) paintWhy(box, known, read);
673
+ else idle();
674
+ }
675
+
676
+ function paintWhy(box, answer, again) {
677
+ const v = whyView(answer);
678
+ box.replaceChildren();
679
+ const para = (text, cls) => {
680
+ const p = document.createElement("p");
681
+ if (cls) p.className = cls;
682
+ p.textContent = text;
683
+ box.appendChild(p);
684
+ return p;
685
+ };
686
+ const list = (title, rows, row) => {
687
+ if (!rows.length) return;
688
+ const t = document.createElement("div");
689
+ t.className = "why-head";
690
+ t.textContent = title;
691
+ const ul = document.createElement("ul");
692
+ ul.className = "why-list";
693
+ for (const r of rows) ul.appendChild(row(r));
694
+ box.append(t, ul);
695
+ };
696
+ const li = (lead, rest, meta) => {
697
+ const el = document.createElement("li");
698
+ const top = document.createElement("div");
699
+ top.append(lead);
700
+ if (rest) top.append(` ${rest}`);
701
+ el.appendChild(top);
702
+ if (meta) {
703
+ const m = document.createElement("div");
704
+ m.className = "why-meta";
705
+ m.textContent = meta;
706
+ el.appendChild(m);
707
+ }
708
+ return el;
709
+ };
710
+ if (v.refusal) {
711
+ para(v.refusal.error, "why-refusal");
712
+ if (v.refusal.remedy) para(v.refusal.remedy, "queried");
713
+ } else {
714
+ para(v.headline, "why-headline");
715
+ list("decisions", v.decisions, (d) => {
716
+ let lead;
717
+ if (d.href) {
718
+ lead = document.createElement("a");
719
+ lead.href = d.href;
720
+ lead.target = "_blank";
721
+ lead.rel = "noreferrer noopener";
722
+ lead.title = "Review this proposed decision in hud";
723
+ } else {
724
+ lead = document.createElement("code");
725
+ }
726
+ lead.textContent = d.id;
727
+ return li(lead, d.title, [d.meta, d.note].filter(Boolean).join(" · "));
728
+ });
729
+ list("runs", v.runs, (r) => {
730
+ const lead = document.createElement("code");
731
+ lead.textContent = r.id;
732
+ return li(lead, r.when ? r.when.slice(0, 10) : "", r.meta);
733
+ });
734
+ list(`commits${v.more ? ` · ${v.commits.length} of ${v.commits.length + v.more}` : ""}`, v.commits, (c) => {
735
+ const lead = document.createElement("code");
736
+ lead.textContent = c.sha;
737
+ return li(lead, c.subject, c.meta);
738
+ });
739
+ for (const g of v.gaps) para(g, "why-gap");
740
+ }
741
+ if (v.footer) para(v.footer, "queried");
742
+ const b = document.createElement("button");
743
+ b.type = "button";
744
+ b.className = "why-read";
745
+ b.textContent = "Read again";
746
+ b.title = "Ask chant again, for changes since this read.";
747
+ b.addEventListener("click", again);
748
+ box.appendChild(b);
749
+ }
750
+
751
+ /** A member's box was picked: what the declaration says of it, and its why. */
752
+ function inspectMember(name) {
753
+ const m = declaredMember(name);
754
+ if (!m) return;
755
+ const panel = document.getElementById("inspect-body");
756
+ panel.innerHTML = "<h2>inspect</h2>";
757
+ panel.dataset.node = `member:${name}`;
758
+ const h = document.createElement("h3");
759
+ h.textContent = "member";
760
+ const dl = document.createElement("dl");
761
+ for (const [k, v] of [["name", m.name], ["dir", m.dir], ["kind", m.kind]]) {
762
+ const dt = document.createElement("dt");
763
+ dt.textContent = k;
764
+ const dd = document.createElement("dd");
765
+ dd.textContent = v;
766
+ dl.append(dt, dd);
767
+ }
768
+ panel.append(h, dl);
769
+ renderWhySection(panel, { member: name });
770
+ }
771
+
582
772
  const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
583
773
 
584
774
  /**
@@ -693,6 +883,27 @@ function inspect(node) {
693
883
  if (node.runtimeOwner) id("runtime owner", node.runtimeOwner);
694
884
  if (node.sourceLoc && node.sourceLoc.file) id("source", node.sourceLoc.file);
695
885
 
886
+ // #490: what terragucci's reports found about this card, each dated and
887
+ // linked to its run. Not a status: the card's fill says nothing about it.
888
+ const tgMarks = tgAnswer && tgAnswer.cards && tgAnswer.cards[node.id];
889
+ if (tgMarks && tgMarks.length) {
890
+ const h = document.createElement("h3");
891
+ h.textContent = "terragucci reports (not live)";
892
+ panel.appendChild(h);
893
+ const now = new Date();
894
+ for (const m of tgMarks) {
895
+ const p = document.createElement("p");
896
+ p.className = "tg-inspect";
897
+ p.textContent = markLine(m, now) + " ";
898
+ p.title = m.addresses.join("\n");
899
+ p.appendChild(tgLink("report", tgHref(tgAnswer, m.run.report)));
900
+ if (m.run.job_url) p.append(" · ", tgLink("job", m.run.job_url));
901
+ if (m.run.pull_request_url) p.append(" · ", tgLink(`PR #${m.run.pull_request}`, m.run.pull_request_url));
902
+ if (m.run.trace_url) p.append(" · ", tgLink("trace", m.run.trace_url));
903
+ panel.appendChild(p);
904
+ }
905
+ }
906
+
696
907
  // Containment hierarchy: a resource shows its parent chain UP (composite →
697
908
  // component); a collapsed composite (detail 1: `attrs.members` is a count)
698
909
  // shows its member list DOWN. The parent chain is on the node itself
@@ -734,6 +945,9 @@ function inspect(node) {
734
945
  });
735
946
  }
736
947
 
948
+ // #471: why this card is the way it is, read from chant when asked.
949
+ renderWhySection(panel, { node: node.id });
950
+
737
951
  // #401: the behaviour block, field by field, with a badge on every figure.
738
952
  // The colour on the card carries one number at a time (whichever mode is
739
953
  // picked) and carries it as a hue; this is where the engine's whole
@@ -1259,6 +1473,7 @@ function wire(ir) {
1259
1473
  selectedNodeId = null;
1260
1474
  view.member = id;
1261
1475
  renderStatusbar();
1476
+ inspectMember(id);
1262
1477
  embedSelected(id, null);
1263
1478
  });
1264
1479
  }
@@ -1284,7 +1499,7 @@ function wire(ir) {
1284
1499
  // member (#475) is the estate member the view is on: a host opens behold with
1285
1500
  // ?member=, and picking a card or a member box moves it. It changes no fetch;
1286
1501
  // the graph is the whole estate, framed on that member's box.
1287
- const view = { member: null, env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1502
+ const view = { member: null, env: null, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1288
1503
 
1289
1504
  // #182: `components` is the boot default, but a project that declares no
1290
1505
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -1312,6 +1527,9 @@ function bootZoom(memberKinds) {
1312
1527
  // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
1313
1528
  // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
1314
1529
  let previewMode = false;
1530
+ // #489: set when /api/project says the repo is a terragucci repo — applied by
1531
+ // its pipeline only, so the page offers no write at all.
1532
+ let terragucciRepo = null;
1315
1533
 
1316
1534
  // The unified "zoom" control (one granularity axis, coarse → fine). Underlying
1317
1535
  // state stays (components, detail); zoom is just the single knob the header
@@ -2173,6 +2391,7 @@ function applyWantedView(want) {
2173
2391
  }
2174
2392
  if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
2175
2393
  if ("radial" in apply) view.radial = apply.radial;
2394
+ if ("gates" in apply) view.gates = apply.gates;
2176
2395
  if (want.member === null) {
2177
2396
  view.member = null;
2178
2397
  selectedNodeId = null;
@@ -2195,7 +2414,12 @@ window.addEventListener("message", (e) => {
2195
2414
  const want = viewFromMessage(e, embedOpts.host);
2196
2415
  if (!want) return;
2197
2416
  const lensBefore = colourMode;
2198
- if (applyWantedView(want)) {
2417
+ const gatesBefore = view.gates;
2418
+ const moved = applyWantedView(want);
2419
+ // #477: the host's env for the gate strip moved. Read it now, not on the
2420
+ // next `changed`; renderStatusbar below writes it into the URL.
2421
+ if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
2422
+ if (moved) {
2199
2423
  resetDialCaches();
2200
2424
  renderStatusbar();
2201
2425
  load();
@@ -2228,10 +2452,10 @@ function embedSelected(member, node) {
2228
2452
  let wsGates = { env: undefined, answer: null, seq: 0 };
2229
2453
  // ?gates=<env> (#477): the env the gate strip reads, apart from the graph's.
2230
2454
  // A host that opens behold on the source graph still wants the gates of the
2231
- // env its block watches.
2232
- const gatesEnvParam = new URLSearchParams(location.search).get("gates") || "";
2455
+ // env its block watches. It is a view key (view-url.js), so the link sets it,
2456
+ // a host's `behold:view` moves it, and the URL keeps it.
2233
2457
  function gatesEnv() {
2234
- return gatesEnvParam || view.env || "";
2458
+ return view.gates || view.env || "";
2235
2459
  }
2236
2460
  function loadWorkspaceGates({ force = false } = {}) {
2237
2461
  if (staticMode) return;
@@ -2429,6 +2653,7 @@ function syncViewUrl() {
2429
2653
  lens: colourMode,
2430
2654
  node: place.node || selectedNodeId,
2431
2655
  radial: view.radial && !view.components && !view.logical && !view.ops,
2656
+ gates: view.gates,
2432
2657
  };
2433
2658
  const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
2434
2659
  const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
@@ -2991,6 +3216,166 @@ function markDriftedCards(ir) {
2991
3216
  }
2992
3217
  }
2993
3218
 
3219
+ // --- #490: a terragucci estate, painted from its reports ----------------------
3220
+ // /api/terragucci answers each root's newest plan, drift and apply run, the
3221
+ // cards they flag and the waves waiting for an approval. Marks are dated
3222
+ // corner glyphs, never a fill: the fill is the live-status channel, and none
3223
+ // of this is live. A waiting wave offers its approve line to copy, no button.
3224
+ let tgAnswer = null;
3225
+ let tgHost = null;
3226
+ async function initTerragucci() {
3227
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3228
+ if (!project.terragucciReports) return;
3229
+ await loadTerragucci();
3230
+ }
3231
+ async function loadTerragucci({ fresh = false } = {}) {
3232
+ const answer = await apiFetch(`/api/terragucci${fresh ? "?fresh=1" : ""}`)
3233
+ .then((r) => r.json())
3234
+ .catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
3235
+ tgAnswer = answer;
3236
+ renderTerragucciPanel();
3237
+ markTerragucciCards();
3238
+ }
3239
+ function markTerragucciCards() {
3240
+ const svg = document.querySelector("#graph svg");
3241
+ if (!svg || !tgAnswer || !tgAnswer.cards) return;
3242
+ const now = new Date();
3243
+ for (const [id, marks] of Object.entries(tgAnswer.cards)) {
3244
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
3245
+ if (!g || g.querySelector('[data-tg-mark="1"]')) continue;
3246
+ const corner = cornerOf(marks);
3247
+ g.classList.add("tg-marked", `tg-${corner.tone}`);
3248
+ const rect = g.querySelector("rect");
3249
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
3250
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
3251
+ tag.setAttribute("data-tg-mark", "1");
3252
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
3253
+ tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
3254
+ tag.setAttribute("text-anchor", "end");
3255
+ tag.setAttribute("font-size", "11");
3256
+ tag.setAttribute("font-weight", "600");
3257
+ tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
3258
+ tag.textContent = `${corner.text} · ${markAge(marks)}`;
3259
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
3260
+ title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
3261
+ g.append(title, tag);
3262
+ }
3263
+ }
3264
+ function markAge(marks) {
3265
+ const newest = marks.map((m) => m.run.finished).sort().pop();
3266
+ const s = Math.max(0, (Date.now() - Date.parse(newest)) / 1000);
3267
+ return s < 3600 ? `${Math.max(1, Math.round(s / 60))}m` : s < 172800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
3268
+ }
3269
+ function tgLink(text, href) {
3270
+ const a = document.createElement("a");
3271
+ a.textContent = text;
3272
+ a.href = href;
3273
+ a.target = "_blank";
3274
+ a.rel = "noopener";
3275
+ return a;
3276
+ }
3277
+ function renderTerragucciPanel() {
3278
+ if (!tgHost) {
3279
+ tgHost = addPanelTab("terragucci", "Terragucci", "What terragucci's reports found: each root's newest drift check, plan and apply wave, dated. Not live.");
3280
+ if (!tgHost) return;
3281
+ tgHost.id = "tab-terragucci";
3282
+ }
3283
+ const host = tgHost;
3284
+ host.replaceChildren();
3285
+ const a = tgAnswer;
3286
+ if (!a || a.error) {
3287
+ const err = document.createElement("div");
3288
+ err.className = "tg-refusal";
3289
+ err.textContent = a ? a.error : "terragucci reports not read";
3290
+ if (a && a.remedy) err.title = a.remedy;
3291
+ const fix = document.createElement("div");
3292
+ fix.className = "tg-legend";
3293
+ fix.textContent = (a && a.remedy) || "";
3294
+ host.append(err, fix);
3295
+ return;
3296
+ }
3297
+ const now = new Date();
3298
+ const head = document.createElement("div");
3299
+ head.className = "tg-legend";
3300
+ const lg = tgLegend(a, now);
3301
+ head.textContent = lg.text;
3302
+ head.title = lg.title;
3303
+ host.appendChild(head);
3304
+ const est = estateLine(a);
3305
+ if (est) {
3306
+ const line = document.createElement("div");
3307
+ line.className = "tg-estate";
3308
+ line.appendChild(tgLink(est.text, est.href));
3309
+ host.appendChild(line);
3310
+ }
3311
+ for (const w of waveCards(a, now)) {
3312
+ const box = document.createElement("div");
3313
+ box.className = "ws-gate tg-wave";
3314
+ const title = document.createElement("div");
3315
+ title.className = "ws-gate-title";
3316
+ title.textContent = w.title;
3317
+ const facts = document.createElement("div");
3318
+ facts.className = "ws-gate-facts";
3319
+ facts.textContent = w.facts;
3320
+ box.append(title, facts);
3321
+ for (const d of w.destroys) {
3322
+ const row = document.createElement("div");
3323
+ row.className = "ws-gate-note";
3324
+ row.textContent = `destroys ${d}`;
3325
+ box.appendChild(row);
3326
+ }
3327
+ const cmd = document.createElement("code");
3328
+ cmd.className = "tg-command";
3329
+ cmd.textContent = w.command;
3330
+ const copy = button("Copy", "", () => copyToClipboard(w.command, copy));
3331
+ copy.title = "Copy the line. Run it at your shell: the approval is yours, signed, in git.";
3332
+ const note = document.createElement("div");
3333
+ note.className = "ws-gate-note";
3334
+ note.textContent = w.note;
3335
+ const links = document.createElement("div");
3336
+ links.className = "ws-gate-note";
3337
+ links.appendChild(tgLink("report", w.report));
3338
+ if (w.job) links.append(" · ", tgLink("job", w.job));
3339
+ box.append(cmd, copy, note, links);
3340
+ host.appendChild(box);
3341
+ }
3342
+ for (const r of tgRootRows(a, now)) {
3343
+ const row = document.createElement("div");
3344
+ row.className = "tg-root";
3345
+ const path = document.createElement("div");
3346
+ path.className = "tg-path";
3347
+ path.textContent = r.path;
3348
+ path.title = `${r.member}/${r.name}`;
3349
+ const cells = document.createElement("div");
3350
+ cells.className = "tg-cells";
3351
+ for (const k of ["drift", "plan", "apply"]) {
3352
+ const span = document.createElement("span");
3353
+ span.className = `tg-${r[k].tone}`;
3354
+ span.dataset.stage = k;
3355
+ if (r[k].href) span.appendChild(tgLink(r[k].text, r[k].href));
3356
+ else span.textContent = r[k].text;
3357
+ if (r[k].title) span.title = r[k].title;
3358
+ cells.appendChild(span);
3359
+ }
3360
+ row.append(path, cells);
3361
+ host.appendChild(row);
3362
+ }
3363
+ if (a.unmatched && (a.unmatched.roots.length || a.unmatched.changes.length)) {
3364
+ const um = document.createElement("div");
3365
+ um.className = "tg-legend";
3366
+ um.textContent = [
3367
+ a.unmatched.roots.length ? `reported roots this checkout does not declare: ${a.unmatched.roots.join(", ")}` : "",
3368
+ a.unmatched.changes.length ? `changes no card is: ${a.unmatched.changes.map((c) => `${c.root}: ${c.address}`).join(", ")}` : "",
3369
+ ].filter(Boolean).join(" · ");
3370
+ host.appendChild(um);
3371
+ }
3372
+ if (!staticMode) {
3373
+ const again = button("Read the reports again", "", () => loadTerragucci({ fresh: true }));
3374
+ host.appendChild(again);
3375
+ }
3376
+ }
3377
+ initTerragucci();
3378
+
2994
3379
  function renderPanelCarve() {
2995
3380
  if (!carveMode()) return;
2996
3381
  if (!carveHost) {
@@ -3407,7 +3792,7 @@ function mountOperator(host) {
3407
3792
  box,
3408
3793
  operatorState,
3409
3794
  {
3410
- approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => approveConvergeGate(op, gate),
3795
+ approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => approveConvergeGate(op, gate),
3411
3796
  approver: projectInfo && projectInfo.approver,
3412
3797
  why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
3413
3798
  // A static export has no server to ask, so it gets no history affordance
@@ -3427,7 +3812,7 @@ function mountPlayhead(host) {
3427
3812
  if (!hasPlayhead(runState)) return;
3428
3813
  const box = document.createElement("div");
3429
3814
  box.className = "run-playhead";
3430
- renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
3815
+ renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
3431
3816
  host.appendChild(box);
3432
3817
  }
3433
3818
 
@@ -3895,6 +4280,7 @@ function render(ir, svg, m) {
3895
4280
  markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
3896
4281
  markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
3897
4282
  markPendingCards(ir); // #422: and the cards whose member has not answered yet
4283
+ markTerragucciCards(); // #490: and what terragucci's reports found, dated
3898
4284
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
3899
4285
  renderDial();
3900
4286
  }
@@ -5199,6 +5585,7 @@ events.addEventListener("member", (e) => {
5199
5585
 
5200
5586
  events.addEventListener("changed", () => {
5201
5587
  bulkDiffCache = null; // an op ran → per-node live state may have changed
5588
+ whyAnswers.clear(); // #471: a why read before the change may no longer hold
5202
5589
  load();
5203
5590
  loadWorkspaceGates({ force: true });
5204
5591
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
@@ -5735,6 +6122,16 @@ async function initActions() {
5735
6122
  // them right now (`/api/project`'s executor block).
5736
6123
  executorInfo = project.executor || {};
5737
6124
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6125
+ if (project.terragucci) {
6126
+ terragucciRepo = project.terragucci;
6127
+ previewMode = true;
6128
+ const pill = document.createElement("span");
6129
+ pill.textContent = "● applied by terragucci";
6130
+ pill.title = `${project.terragucci.error}\n\n${project.terragucci.remedy}`;
6131
+ pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
6132
+ bar.appendChild(pill);
6133
+ return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
6134
+ }
5738
6135
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
5739
6136
  .then((r) => r.json())
5740
6137
  .catch(() => ({ ops: [], adoptLexicons: [] }));
@@ -6080,7 +6477,7 @@ function paletteCommands() {
6080
6477
  c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
6081
6478
  }
6082
6479
 
6083
- if (staticMode) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export
6480
+ if (staticMode || terragucciRepo) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export or a terragucci repo
6084
6481
 
6085
6482
  // Deploy / write actions — previewMode hides exactly the git/PR write
6086
6483
  // affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
package/web/embed.js CHANGED
@@ -61,9 +61,11 @@ export function viewFromMessage(event, host) {
61
61
  if (typeof v === "boolean") want.radial = v;
62
62
  continue;
63
63
  }
64
- if (k === "env") {
65
- if (v === null || v === "") want.env = null;
66
- else if (typeof v === "string") want.env = v;
64
+ if (k === "env" || k === "gates") {
65
+ // env: null or "" is the source graph. gates: null or "" hands the
66
+ // gate strip back to the graph's env.
67
+ if (v === null || v === "") want[k] = null;
68
+ else if (typeof v === "string") want[k] = v;
67
69
  continue;
68
70
  }
69
71
  if (k === "member" && v === null) {
package/web/embed.test.js CHANGED
@@ -37,6 +37,12 @@ describe("viewFromMessage (#476)", () => {
37
37
  it("reads member null as the whole estate and env empty as the source", () => {
38
38
  expect(viewFromMessage({ origin: host, data: { type: "behold:view", member: null, env: "" } }, host)).toEqual({ member: null, env: null });
39
39
  });
40
+ it("takes gates, the gate strip's env, and null or empty as following the graph's env (#477)", () => {
41
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "prod" } }, host)).toEqual({ gates: "prod" });
42
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "" } }, host)).toEqual({ gates: null });
43
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: null } }, host)).toEqual({ gates: null });
44
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: 3 } }, host)).toEqual({});
45
+ });
40
46
  it("ignores another origin, another type, and any message when there is no host", () => {
41
47
  expect(viewFromMessage({ origin: "http://evil", data: { type: "behold:view", member: "x" } }, host)).toBe(null);
42
48
  expect(viewFromMessage({ origin: host, data: { type: "other" } }, host)).toBe(null);