@intentius/behold 0.14.0 → 0.15.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/AGENTS.md CHANGED
@@ -54,6 +54,13 @@ preview-locked, and one load runs at a time (409 otherwise).
54
54
  per-node observed state, drift and field ownership; `/api/reconcile?env=`
55
55
  summarizes the pending change set; `/api/substrates` reports substrate
56
56
  readiness; `/api/events` (SSE) pushes `changed`/`op`/`apply`/`run`/`pr`.
57
+ With `?components=1&env=`, each component node carries `_liveStatus` (the
58
+ `chant components status` verdict) and, when the release ledger recorded
59
+ one, `_release` — `{runId, forge, originSource, url?, gitSha, digest,
60
+ timestamp, actor, approver?}` (#165). `url` is present only when the record
61
+ itself carries an address (chant#2045); `originSource: "inferred"` means
62
+ behold read the id's spelling and nothing more, and a `forge: "unknown"` id
63
+ is never resolved to a link on behold's guess.
57
64
  `?ops=1` is the ops lens — the project's declared Ops as a phase track, read
58
65
  from each emitted `dist/ops/<name>/op.json`, with the current run painted
59
66
  over it (`meta.run`, `meta.gate`); `/api/ops/<name>/status` reads that Op's
@@ -257,3 +264,39 @@ own Terraform is not edited either. The only request-derived value is `select`,
257
264
  and it must be an address the served report already ranks — the value that
258
265
  reaches the spawn's argv comes from a closed set read off disk. No cloud write,
259
266
  no Terraform mutation, no edit to anyone's chant source.
267
+
268
+ ## Changing behold (for agents working on this repo)
269
+
270
+ The sections above are about driving a running behold. This one is about
271
+ changing it — the working rules that used to live in a handoff note and now
272
+ live here, with the enforceable ones enforced.
273
+
274
+ - **Branches and merges.** Work on a branch off `main` (an isolated worktree
275
+ when more than one agent is active). `main` is protected by a ruleset: every
276
+ change arrives as a PR, and the `check` job in `ci.yml` must be green before
277
+ it merges. A check that has merely settled is not a pass — read the lines.
278
+ No `gh pr merge --auto`; merge one PR at a time, in order, and rebase the
279
+ next onto what landed.
280
+ - **The local gate** is `just check` (tsc, tests, build). `just test` keeps
281
+ the reporter's full output in `vitest.log`, because #334's one-shot
282
+ first-run failures kept losing the failing file's name — if the suite fails
283
+ once and passes on rerun, the log from the first run is the evidence; attach
284
+ it to #334.
285
+ - **Releasing** is `just release`, after the version-bump PR merged: it tags
286
+ `behold-v<version>`, pushes the tag (which is what runs `release.yml`), and
287
+ waits for npm to show the version. It refuses in every state where a tag
288
+ push would be wrong. Never re-push a `behold-v*` tag by hand — a tag push
289
+ re-fires publish. Tags absent from origin (behold-v0.10.0, 0.10.1) stay
290
+ absent for that reason.
291
+ - **Scratch infrastructure** is governed by `src/scratch.ts`, asserted by
292
+ `scratch.test.ts` across every boot site: anything behold boots is named
293
+ `behold-*`, refuses if the name is taken, tears down only what it created,
294
+ and never binds the shared emulator ports. The protected names — `floci*`,
295
+ `chant-floci*`, the kubemicrovm and fountain k3d clusters, :4566 — are
296
+ listed there, not in prose. A new boot site calls `assertScratch` before
297
+ its spawn and gets a row in the test.
298
+ - **The write boundary** is the Invariant section above. Apply is never a
299
+ button; `/api/carve/apply` does not exist and the route test asserts it
300
+ stays that way.
301
+ - **chant's working rules** (its single-lane checkout, its release path) are
302
+ chant's and live in chant's repo, not here.
package/dist/cli.js CHANGED
@@ -2871,6 +2871,50 @@ function componentStatusColor(row) {
2871
2871
  return "neutral";
2872
2872
  }
2873
2873
  }
2874
+ function httpUrl(url) {
2875
+ if (typeof url !== "string" || !url) return void 0;
2876
+ try {
2877
+ const p = new URL(url);
2878
+ return p.protocol === "http:" || p.protocol === "https:" ? url : void 0;
2879
+ } catch {
2880
+ return void 0;
2881
+ }
2882
+ }
2883
+ function releaseOrigin(rec6) {
2884
+ const origin = rec6.runOrigin;
2885
+ if (origin && typeof origin.forge === "string") {
2886
+ const url = httpUrl(origin.url);
2887
+ return {
2888
+ forge: origin.forge,
2889
+ originSource: "record",
2890
+ ...origin.repo ? { repo: origin.repo } : {},
2891
+ ...url ? { url } : {}
2892
+ };
2893
+ }
2894
+ return { forge: rec6.runId.startsWith("local-") ? "local" : "unknown", originSource: "inferred" };
2895
+ }
2896
+ var FORGE_WORD = {
2897
+ github: "github",
2898
+ gitlab: "gitlab",
2899
+ op: "op",
2900
+ local: "laptop",
2901
+ unknown: "?"
2902
+ };
2903
+ function releaseChip(status) {
2904
+ return `${status.gitSha.slice(0, 7)} via ${FORGE_WORD[status.forge]}`;
2905
+ }
2906
+ function releaseStatus(rec6) {
2907
+ if (!rec6) return void 0;
2908
+ return {
2909
+ runId: rec6.runId,
2910
+ ...releaseOrigin(rec6),
2911
+ gitSha: rec6.gitSha,
2912
+ digest: rec6.digest,
2913
+ timestamp: rec6.timestamp,
2914
+ actor: rec6.actor,
2915
+ ...rec6.approver ? { approver: rec6.approver } : {}
2916
+ };
2917
+ }
2874
2918
  function joinComponentStatus(ir, rows) {
2875
2919
  const byComponent = new Map(rows.map((r) => [r.component, r]));
2876
2920
  return {
@@ -2878,10 +2922,12 @@ function joinComponentStatus(ir, rows) {
2878
2922
  nodes: ir.nodes.map((n) => {
2879
2923
  const row = byComponent.get(n.id);
2880
2924
  if (!row) return n;
2925
+ const release = releaseStatus(row.recorded);
2881
2926
  return {
2882
2927
  ...n,
2883
2928
  attrs: {
2884
2929
  ...n.attrs,
2930
+ ...release ? { release: releaseChip(release), _release: release } : {},
2885
2931
  _status: componentStatusColor(row),
2886
2932
  _liveStatus: {
2887
2933
  reconciliation: row.reconciliation,
@@ -6595,6 +6641,34 @@ function reanchorEdges(svg, moved) {
6595
6641
  import { spawn as spawn3 } from "node:child_process";
6596
6642
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
6597
6643
  import { join as join12 } from "node:path";
6644
+
6645
+ // src/scratch.ts
6646
+ var PROTECTED_INFRA = [
6647
+ /^floci(-|$)/,
6648
+ /^chant-floci(-|$)/,
6649
+ /^k3d-kubemicrovm-local$/,
6650
+ /^k3d-fountain-local$/,
6651
+ /^kubemicrovm-local$/,
6652
+ /^fountain-local$/
6653
+ ];
6654
+ var PROTECTED_PORTS = [4566, 4577, 4588];
6655
+ var SCRATCH_PREFIX = "behold-";
6656
+ function scratchNameProblem(name) {
6657
+ if (!name.startsWith(SCRATCH_PREFIX)) return `scratch infra must be named ${SCRATCH_PREFIX}*, got ${JSON.stringify(name)}`;
6658
+ const hit = PROTECTED_INFRA.find((re) => re.test(name));
6659
+ if (hit) return `${JSON.stringify(name)} is protected infrastructure (${hit}) \u2014 behold never touches it`;
6660
+ return void 0;
6661
+ }
6662
+ function scratchPortProblem(port) {
6663
+ if (PROTECTED_PORTS.includes(port)) return `port ${port} belongs to a shared emulator \u2014 a scratch substrate gets its own`;
6664
+ return void 0;
6665
+ }
6666
+ function assertScratch(name, port) {
6667
+ const p = scratchNameProblem(name) ?? (port === void 0 ? void 0 : scratchPortProblem(port));
6668
+ if (p) throw new Error(`scratch discipline: ${p}`);
6669
+ }
6670
+
6671
+ // src/carve-live.ts
6598
6672
  var LIVE_CONTAINER = "behold-carve-floci";
6599
6673
  var LIVE_PORT = 4602;
6600
6674
  var LIVE_IMAGE = "floci/floci:1.5.34";
@@ -6627,6 +6701,7 @@ function capture(cmd, args, cwd) {
6627
6701
  });
6628
6702
  }
6629
6703
  async function bootScratchFloci() {
6704
+ assertScratch(LIVE_CONTAINER, LIVE_PORT);
6630
6705
  const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
6631
6706
  if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
6632
6707
  if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": {
package/web/app.js CHANGED
@@ -52,6 +52,10 @@ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
52
52
  // records a fact for the next tick rather than releasing anything. Same split as
53
53
  // the playhead — the decisions are pure in there, the fetches stay here.
54
54
  import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
55
+ // #165: the recorded release on a component — which run put it here, from
56
+ // which commit, approved by whom — and, when the ledger's run id cannot be
57
+ // followed, the reason in words rather than a guessed link.
58
+ import { releaseRows } from "./release.js";
55
59
  initTheme();
56
60
  initPanel();
57
61
  mountThemePicker(document.getElementById("panel-theme"));
@@ -542,8 +546,35 @@ function inspect(node) {
542
546
  }
543
547
  }
544
548
 
549
+ // The recorded release (#165, #61's second clause): the ledger row behold
550
+ // reads on `chant components status` and, until now, read past. Its own
551
+ // section beside "live status", because the two answer different questions
552
+ // — what is running versus what was recorded putting it there — and the
553
+ // reconciliation verdict above is exactly their comparison. The run is a link
554
+ // only when the record itself carries an address (chant#2045); a bare id
555
+ // gets its reason, never a guessed forge (web/release.js).
556
+ const release = node.attrs && node.attrs._release;
557
+ if (release) {
558
+ const rel = section("release");
559
+ for (const [k, v] of releaseRows(release)) {
560
+ if (v && typeof v === "object" && v.href) {
561
+ const a = document.createElement("a");
562
+ a.href = v.href;
563
+ a.textContent = v.text;
564
+ a.target = "_blank";
565
+ a.rel = "noreferrer noopener";
566
+ a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
567
+ rel(k, a);
568
+ } else {
569
+ rel(k, v);
570
+ }
571
+ }
572
+ }
573
+
545
574
  // Declared attributes — the source-of-truth values / cross-resource refs.
546
- const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
575
+ // The `release` chip is the card's abbreviation of the section above, not a
576
+ // declared value, so it stays out of this list when the section rendered.
577
+ const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
547
578
  if (attrKeys.length) {
548
579
  const decl = section("declared");
549
580
  for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
package/web/release.js ADDED
@@ -0,0 +1,66 @@
1
+ // #165 (#61's second clause): the recorded release, read off a component
2
+ // node's `_release` attr (src/component-status.ts's `releaseStatus`). The pure
3
+ // half, tested the way web/operator.js and web/run-playhead.js are — no DOM.
4
+ // The card chip (`release`) already answers "which commit, through what"; this
5
+ // is the pane's fuller answer — which run, from which commit, approved by whom
6
+ // — and, when the record cannot be followed, the reason in words.
7
+ import { approvalLink } from "./operator.js";
8
+ import { waitingFor } from "./run-playhead.js";
9
+
10
+ /** How the pane names each id space. `laptop` for a local mint: "local" would
11
+ * read as a place, and the point is that there is nowhere to go. */
12
+ export const FORGE_LABEL = { github: "GitHub Actions", gitlab: "GitLab CI", op: "an Op run", local: "a laptop run", unknown: "an unlinked run" };
13
+
14
+ /** The one line that says why there is no link, or null when there is one.
15
+ * Never a placeholder link, never a guessed forge: the three id spaces a
16
+ * `runId` can live in are mutually incompatible, so a link behold built from
17
+ * a bare id would as often be wrong as right (chant#2045). */
18
+ export function unlinkedReason(release) {
19
+ if (!release) return null;
20
+ if (runLink(release)) return null;
21
+ switch (release.forge) {
22
+ case "local":
23
+ return release.originSource === "record"
24
+ ? "minted on the machine that deployed — the record says so; there is no run to open"
25
+ : "minted on the machine that deployed (a local- id) — there is no run to open";
26
+ case "unknown":
27
+ return "the record names a run id and nothing else — no forge, no repo, no URL. A chant older than chant#2045 wrote it; behold will not guess which forge the id belongs to";
28
+ case "op":
29
+ return "an orchestrator run id — the run lives in Temporal, not on a forge";
30
+ default:
31
+ return `${FORGE_LABEL[release.forge] || release.forge} named no server URL when this was recorded, so the id cannot be resolved to a link`;
32
+ }
33
+ }
34
+
35
+ /** The run's absolute http(s) URL, or null. Same validator as the gate card's
36
+ * `approve at:` — an address the ledger itself carries, or nothing. */
37
+ export function runLink(release) {
38
+ return release && release.originSource === "record" ? approvalLink(release.url) : null;
39
+ }
40
+
41
+ /** `<runId> (<forge>)` — the run row's text, link or no link. */
42
+ export function runText(release) {
43
+ const forge = FORGE_LABEL[release.forge] || release.forge;
44
+ return release.repo ? `${release.runId} · ${forge} · ${release.repo}` : `${release.runId} · ${forge}`;
45
+ }
46
+
47
+ /** The pane's rows, in order: `[key, value]` pairs plus an optional caveat
48
+ * row keyed `""`. The link is returned as `{href, text}` so the DOM half makes
49
+ * the anchor and this half stays testable. */
50
+ export function releaseRows(release, now = Date.now()) {
51
+ if (!release) return [];
52
+ const rows = [];
53
+ const href = runLink(release);
54
+ rows.push(["run", href ? { href, text: runText(release) } : runText(release)]);
55
+ const why = unlinkedReason(release);
56
+ if (why) rows.push(["", why]);
57
+ rows.push(["commit", release.gitSha]);
58
+ rows.push(["digest", release.digest]);
59
+ const ago = release.timestamp ? waitingFor(release.timestamp, now) : null;
60
+ rows.push(["recorded", ago ? `${release.timestamp} (${ago} ago)` : release.timestamp]);
61
+ rows.push(["actor", release.actor]);
62
+ // Absent means ungated — chant omits `approver` exactly then — so the row
63
+ // says that rather than leaving a gap the reader could take for a redaction.
64
+ rows.push(["approver", release.approver || "none — an ungated change"]);
65
+ return rows;
66
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { releaseRows, runLink, unlinkedReason, runText } from "../web/release.js";
3
+
4
+ const NOW = Date.parse("2026-09-01T12:00:00Z");
5
+ const base = { runId: "18234771902", gitSha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", digest: "sha256:86bf5e3c", timestamp: "2026-09-01T11:30:00Z", actor: "ci-bot" };
6
+
7
+ describe("release rows (#165)", () => {
8
+ it("a record that carries its own address links the run, and names forge + repo", () => {
9
+ const r = { ...base, forge: "github", originSource: "record", repo: "INTENTIUS/loomster", url: "https://github.com/INTENTIUS/loomster/actions/runs/18234771902", approver: "alex" };
10
+ const rows = releaseRows(r, NOW);
11
+ expect(rows[0]).toEqual(["run", { href: r.url, text: "18234771902 · GitHub Actions · INTENTIUS/loomster" }]);
12
+ expect(rows.find(([k]) => k === "")).toBeUndefined();
13
+ expect(rows.find(([k]) => k === "approver")[1]).toBe("alex");
14
+ expect(rows.find(([k]) => k === "recorded")[1]).toBe("2026-09-01T11:30:00Z (30m ago)");
15
+ });
16
+
17
+ it("a bare id from a pre-chant#2045 record is never linked, and the caveat says why", () => {
18
+ const r = { ...base, forge: "unknown", originSource: "inferred" };
19
+ expect(runLink(r)).toBeNull();
20
+ expect(unlinkedReason(r)).toMatch(/chant#2045/);
21
+ expect(unlinkedReason(r)).toMatch(/will not guess/);
22
+ expect(releaseRows(r, NOW)[0]).toEqual(["run", "18234771902 · an unlinked run"]);
23
+ });
24
+
25
+ it("a laptop run says there is nowhere to go — whether the record said local or the id's spelling did", () => {
26
+ const said = { ...base, runId: "local-1756726200000", forge: "local", originSource: "record" };
27
+ const spelled = { ...base, runId: "local-1756726200000", forge: "local", originSource: "inferred" };
28
+ expect(unlinkedReason(said)).toMatch(/the record says so/);
29
+ expect(unlinkedReason(spelled)).toMatch(/a local- id/);
30
+ expect(runText(said)).toBe("local-1756726200000 · a laptop run");
31
+ });
32
+
33
+ it("an inferred origin never yields a link even if a url is somehow present", () => {
34
+ expect(runLink({ ...base, forge: "github", originSource: "inferred", url: "https://example.com/run" })).toBeNull();
35
+ });
36
+
37
+ it("a recorded origin with a non-http url is not a link — the gate card's rule", () => {
38
+ const r = { ...base, forge: "github", originSource: "record", url: "javascript:alert(1)" };
39
+ expect(runLink(r)).toBeNull();
40
+ expect(unlinkedReason(r)).toMatch(/named no server URL/);
41
+ });
42
+
43
+ it("an ungated change says so in the approver row rather than leaving a gap", () => {
44
+ const rows = releaseRows({ ...base, forge: "op", originSource: "record" }, NOW);
45
+ expect(rows.find(([k]) => k === "approver")[1]).toBe("none — an ungated change");
46
+ expect(unlinkedReason({ ...base, forge: "op", originSource: "record" })).toMatch(/Temporal/);
47
+ });
48
+
49
+ it("nothing recorded → no rows", () => {
50
+ expect(releaseRows(undefined)).toEqual([]);
51
+ });
52
+ });