@intentius/behold 0.13.0 → 0.14.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.
@@ -6,9 +6,9 @@
6
6
  "": {
7
7
  "name": "behold-example-writes",
8
8
  "dependencies": {
9
- "@intentius/chant": "^0.52.1",
10
- "@intentius/chant-lexicon-aws": "^0.52.1",
11
- "@intentius/chant-lexicon-temporal": "^0.52.1"
9
+ "@intentius/chant": "^0.53.1",
10
+ "@intentius/chant-lexicon-aws": "^0.53.1",
11
+ "@intentius/chant-lexicon-temporal": "^0.53.1"
12
12
  }
13
13
  },
14
14
  "node_modules/@dagrejs/dagre": {
@@ -443,9 +443,9 @@
443
443
  }
444
444
  },
445
445
  "node_modules/@intentius/chant": {
446
- "version": "0.52.1",
447
- "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.52.1.tgz",
448
- "integrity": "sha512-JvlJ8JrmcDWF6IhmO1aJPgBCMIoTT9xUu/4sOs+3O2NSILZFJBJ3gN23FMW3goN+HP0HbniPtR/MH5cklpam0Q==",
446
+ "version": "0.53.1",
447
+ "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.53.1.tgz",
448
+ "integrity": "sha512-AIVy6/NQxnplHPip+oYhbgr1qe8QCzUc7heqiXoV9OZynn6x+/4sflrFha98dOotJ7Fraj2/QjfGQ+q1DpXK8w==",
449
449
  "license": "Apache-2.0",
450
450
  "dependencies": {
451
451
  "@dagrejs/dagre": "^3.0.0",
@@ -461,30 +461,30 @@
461
461
  }
462
462
  },
463
463
  "node_modules/@intentius/chant-lexicon-aws": {
464
- "version": "0.52.1",
465
- "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.52.1.tgz",
466
- "integrity": "sha512-TuTt3AMfAZmtuyfYGijtO5HjtHab9XSE1f2a51PjfzbgBspwQT1dfMa1SGnGsDbtgtaE1sUoe48bbTfU12dQmw==",
464
+ "version": "0.53.1",
465
+ "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.53.1.tgz",
466
+ "integrity": "sha512-LyI7IMib1m69k+oNovrBw/RNJqIM17vSj9ew+h5kviGf3KHfyU0VxPX5n6mxqZCNK5LAoMtRlW/cdzN2caJAWQ==",
467
467
  "license": "Apache-2.0",
468
468
  "dependencies": {
469
469
  "fflate": "^0.8.2",
470
470
  "js-yaml": "^4.3.1"
471
471
  },
472
472
  "peerDependencies": {
473
- "@intentius/chant": "^0.52.1",
473
+ "@intentius/chant": "^0.53.1",
474
474
  "typescript": "^5.9.3"
475
475
  }
476
476
  },
477
477
  "node_modules/@intentius/chant-lexicon-temporal": {
478
- "version": "0.52.1",
479
- "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.52.1.tgz",
480
- "integrity": "sha512-tVBZTPF3FZSeyLgHSid8+6jrxii/pRQEW4na8M4FnA5kGbUKTL8+Ckwsobp9nIE7HFWfBKdLcoCNt6Guw/tEfA==",
478
+ "version": "0.53.1",
479
+ "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.53.1.tgz",
480
+ "integrity": "sha512-zcj1OzDgOXUG/c50Bd3FWW7arodD72Wm50CItar0XBcXEV/w+gZaB/ksXx/D5vrRJvccZP2oIMSAsdLaVRso4w==",
481
481
  "license": "Apache-2.0",
482
482
  "dependencies": {
483
483
  "@temporalio/common": "^1.17.2",
484
484
  "js-yaml": "^4.3.1"
485
485
  },
486
486
  "peerDependencies": {
487
- "@intentius/chant": "^0.52.1",
487
+ "@intentius/chant": "^0.53.1",
488
488
  "typescript": "^5.9.3",
489
489
  "zod": "^4.3.6"
490
490
  }
@@ -7,8 +7,8 @@
7
7
  "build": "./node_modules/.bin/chant build src --lexicon aws -o template.json"
8
8
  },
9
9
  "dependencies": {
10
- "@intentius/chant": "^0.52.1",
11
- "@intentius/chant-lexicon-aws": "^0.52.1",
12
- "@intentius/chant-lexicon-temporal": "^0.52.1"
10
+ "@intentius/chant": "^0.53.1",
11
+ "@intentius/chant-lexicon-aws": "^0.53.1",
12
+ "@intentius/chant-lexicon-temporal": "^0.53.1"
13
13
  }
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {
@@ -35,9 +35,9 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@hono/node-server": "^2.0.5",
38
- "@intentius/chant": "^0.52.1",
39
- "@intentius/chant-k8s-client": "^0.52.1",
40
- "@intentius/pinhole": "^0.3.5",
38
+ "@intentius/chant": "^0.53.1",
39
+ "@intentius/chant-k8s-client": "^0.53.1",
40
+ "@intentius/pinhole": "^0.3.7",
41
41
  "hono": "^4.12.34"
42
42
  },
43
43
  "devDependencies": {
package/web/app.js CHANGED
@@ -46,7 +46,7 @@ import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
46
46
  // #284 item 2: the run playhead's panel — the verdict wording, the pending gate
47
47
  // card and the settled-step rows. Everything it DECIDES is pure in there; the
48
48
  // fetches, the SSE subscription and the graph re-pull stay here.
49
- import { hasPlayhead, renderPlayhead } from "./run-playhead.js";
49
+ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
50
50
  // #234 joins 3 + 1: the operator strip (one honestly-dated tick per ConvergeOp,
51
51
  // the lease, the pending gate count) and the converge gate card, whose Approve
52
52
  // records a fact for the next tick rather than releasing anything. Same split as
@@ -382,6 +382,11 @@ function inspect(node) {
382
382
  // joinComponentStatus, so its presence picks the component-status label set
383
383
  // over the entity overlay's managed/foreign/pending.
384
384
  const liveStatus = node.attrs && node.attrs._liveStatus;
385
+ // chant#2027: what the operating loop's last tick said about this component
386
+ // (src/component-status.ts's joinTickVerdicts). Same component-status
387
+ // vocabulary as `_liveStatus`, one tier lower and dated — so it picks the same
388
+ // label set, and it may sit alongside a live verdict rather than replacing it.
389
+ const tickStatus = node.attrs && node.attrs._tickStatus;
385
390
  // behold#146: a Helm chart's status is artifact presence, not management —
386
391
  // the join sets `_artifact` on a match, and every Helm::Chart in an overlay
387
392
  // went through it, so the kind alone is the reliable picker.
@@ -396,7 +401,7 @@ function inspect(node) {
396
401
  "status",
397
402
  driftLabel ||
398
403
  (node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
399
- (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
404
+ (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
400
405
  st,
401
406
  );
402
407
  if (node.attrs && node.attrs._artifact) {
@@ -514,6 +519,29 @@ function inspect(node) {
514
519
  live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
515
520
  }
516
521
 
522
+ // What the operating loop's last tick said about this component (chant#2027).
523
+ // Its own section, never folded into "live status": a tick is dated, and the
524
+ // date is the point. Shown alongside a live verdict when there is one — the
525
+ // two can disagree, and which is newer is the reader's to see, not behold's to
526
+ // resolve. The heading itself carries the age, and a tick too old to have
527
+ // painted says so in words rather than leaving its verdict to be read as now.
528
+ if (tickStatus) {
529
+ const ago = tickStatus.at ? waitingFor(tickStatus.at) : null;
530
+ const tick = section(`converge tick${ago ? ` · ${ago} ago` : ""}${tickStatus.stale ? " · not painted" : ""}`);
531
+ tick("reconciliation", tickStatus.reconciliation);
532
+ if (tickStatus.detail) tick("detail", tickStatus.detail);
533
+ if (tickStatus.live !== undefined) tick("observed live", String(tickStatus.live));
534
+ if (tickStatus.unobserved) {
535
+ tick("unobserved", tickStatus.unobserved.detail ? `${tickStatus.unobserved.reason} — ${tickStatus.unobserved.detail}` : tickStatus.unobserved.reason);
536
+ }
537
+ tick("recorded by", tickStatus.env ? `${tickStatus.op} (${tickStatus.env})` : tickStatus.op);
538
+ tick("at", tickStatus.at);
539
+ if (tickStatus.tickId) tick("tick", tickStatus.tickId);
540
+ if (tickStatus.stale) {
541
+ tick("", "older than the operator's own cadence allows for — this verdict is history, so no colour was taken from it");
542
+ }
543
+ }
544
+
517
545
  // Declared attributes — the source-of-truth values / cross-resource refs.
518
546
  const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
519
547
  if (attrKeys.length) {
@@ -589,7 +617,7 @@ function inspect(node) {
589
617
  // `observed` map already carries a runtime child (chant's describeResources
590
618
  // reports it the same as any other resource it found), and its own
591
619
  // `fieldDrift` may apply too on a substrate with per-field ownership.
592
- const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
620
+ const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus && !tickStatus);
593
621
  if (view.env && observed) {
594
622
  const forId = node.id;
595
623
  const loading = document.createElement("p");
@@ -1867,6 +1895,11 @@ let runStatusInFlight = false;
1867
1895
  // playhead's reason: a loop belongs to its project, not to a picked env/tier.
1868
1896
  let operatorState = null;
1869
1897
  let operatorInFlight = false;
1898
+ // #234, chant#2029 — the converge timeline behind the strip. Closed until a
1899
+ // human opens it, read once when they do, and never on a timer: the strip is the
1900
+ // at-a-glance line that ages by itself, the history is a ledger's past and does
1901
+ // not change while you read it. `data` is src/operator.ts's OperatorHistory.
1902
+ let operatorHistory = { open: false, loading: false, error: null, data: null };
1870
1903
  let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
1871
1904
  let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
1872
1905
 
@@ -2067,7 +2100,17 @@ function mountOperator(host) {
2067
2100
  if (!view.ops || !hasOperator(operatorState)) return;
2068
2101
  const box = document.createElement("div");
2069
2102
  box.className = "operator-strip";
2070
- renderOperator(box, operatorState, { approve: (op, gate) => approveConvergeGate(op, gate) });
2103
+ renderOperator(
2104
+ box,
2105
+ operatorState,
2106
+ {
2107
+ approve: (op, gate) => approveConvergeGate(op, gate),
2108
+ // A static export has no server to ask, so it gets no history affordance
2109
+ // at all rather than a button that can only fail.
2110
+ ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
2111
+ },
2112
+ operatorHistory,
2113
+ );
2071
2114
  host.appendChild(box);
2072
2115
  }
2073
2116
 
@@ -3816,6 +3859,48 @@ function pollOperatorStatus() {
3816
3859
  });
3817
3860
  }
3818
3861
 
3862
+ /**
3863
+ * Open or close the converge history (#234, chant#2029), reading it the first
3864
+ * time it is opened.
3865
+ *
3866
+ * This is the whole poll discipline for the timeline: a click. `chant operator
3867
+ * log` walks the project's `chant/lifecycle` ledger, and an interval against it
3868
+ * would spend a process per tick of the clock to be handed a past that hasn't
3869
+ * moved. Re-opening re-reads, so a human who wants the newest ticks closes and
3870
+ * opens — an explicit ask, which is what this surface is.
3871
+ */
3872
+ function toggleOperatorHistory() {
3873
+ operatorHistory = { ...operatorHistory, open: !operatorHistory.open };
3874
+ if (operatorHistory.open) loadOperatorHistory();
3875
+ renderDial();
3876
+ }
3877
+
3878
+ /**
3879
+ * Read one bounded window of the converge timeline.
3880
+ *
3881
+ * The server clamps and reports the window it used (src/operator.ts's
3882
+ * OPERATOR_LOG_MAX_LIMIT), so the panel states the bound rather than implying it
3883
+ * is showing everything. A refusal — a chant older than the `operator log`
3884
+ * subcommand, an unreadable answer — lands on the panel as its own sentence: an
3885
+ * empty timeline must never stand in for "behold couldn't ask".
3886
+ */
3887
+ function loadOperatorHistory() {
3888
+ if (staticMode || operatorHistory.loading) return;
3889
+ operatorHistory = { ...operatorHistory, loading: true, error: null };
3890
+ apiFetch("/api/operator/log")
3891
+ .then((r) => r.json())
3892
+ .then((j) => {
3893
+ operatorHistory = j && j.history
3894
+ ? { open: true, loading: false, error: null, data: j.history }
3895
+ : { open: true, loading: false, error: (j && j.error) || "the server answered with no timeline", data: null };
3896
+ renderDial();
3897
+ })
3898
+ .catch((e) => {
3899
+ operatorHistory = { open: true, loading: false, error: String(e), data: null };
3900
+ renderDial();
3901
+ });
3902
+ }
3903
+
3819
3904
  /**
3820
3905
  * Record a converge gate's resolution — `chant approve <op> <gate>`, delegated
3821
3906
  * exactly as the run gate's signal is.
@@ -3837,6 +3922,9 @@ function approveConvergeGate(op, gate) {
3837
3922
  showToast(`✓ ${gate} — ${APPROVED_SEMANTICS}`, true);
3838
3923
  }
3839
3924
  pollOperatorStatus();
3925
+ // A resolution just became a ledger record. Re-read the timeline only if
3926
+ // it is already open — still a human's ask, not a background refresh.
3927
+ if (operatorHistory.open) loadOperatorHistory();
3840
3928
  });
3841
3929
  }
3842
3930
 
package/web/index.html CHANGED
@@ -390,9 +390,31 @@
390
390
  .operator-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
391
391
  overflow-wrap: anywhere; }
392
392
  .operator-gates { font: var(--t-caption)/1.6 var(--font-mono); color: var(--pending); flex: none; }
393
- /* Why one line is all there is (chant#2029). A statement, not an alarm —
394
- the same voice .run-lost uses for what behold does not know. */
393
+ /* Why one line is all the strip has, and where the rest is (chant#2029).
394
+ A statement, not an alarm — the same voice .run-lost uses for what
395
+ behold does not know. */
395
396
  .operator-caveat { font-size: var(--t-caption); color: var(--muted); }
397
+ /* The converge history (chant#2029) — the timeline the strip is NOT,
398
+ reached from it. Closed by default and read on the click that opens it,
399
+ so this whole block is inert until a human asks for it. */
400
+ .operator-history { display: flex; flex-direction: column; gap: 4px; }
401
+ .operator-history-toggle { align-self: flex-start; background: none; color: var(--pending);
402
+ border: none; padding: 0; font-size: var(--t-caption); cursor: pointer; }
403
+ .operator-history-head { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted); }
404
+ /* Ticks and gate resolutions, oldest first — chant's own order. The rule
405
+ down the left is the lane: one column of time, read forward. */
406
+ .operator-timeline { display: flex; flex-direction: column; gap: 6px;
407
+ border-left: 1px solid var(--rule); padding-left: 8px; max-height: 320px; overflow-y: auto; }
408
+ .operator-entry { display: flex; flex-direction: column; gap: 1px; }
409
+ /* A resolution is the human's half of the loop, so it wears the gate's own
410
+ colour rather than the tick's. */
411
+ .operator-entry-gate-resolution .operator-meta { color: var(--managed); }
412
+ .operator-gated { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign);
413
+ overflow-wrap: anywhere; }
414
+ /* chant#2028's address. A link only ever appears where a fact carried one
415
+ — there is no placeholder state for this rule to style. */
416
+ .operator-url { display: block; font: var(--t-caption)/1.5 var(--font-mono); color: var(--pending);
417
+ overflow-wrap: anywhere; }
396
418
  /* The converge gate's own two lines, inside the shared .run-gate card:
397
419
  which loop this is, and what approving actually does. */
398
420
  .operator-loop { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign); }
package/web/operator.js CHANGED
@@ -9,13 +9,20 @@
9
9
  *
10
10
  * What it renders, from the `operator` state the server broadcasts:
11
11
  * - one line per ConvergeOp: the last tick's own log line, verbatim, honestly
12
- * dated; the lease; the pending-gate count. ONE line, because chant's status
13
- * surface exposes one tick and no history (chant#2029) — this is a strip, and
14
- * there is deliberately no code path here that can render a second tick.
12
+ * dated; the lease; the pending-gate count. ONE line, because that is what
13
+ * `chant operator status` answers with — there is deliberately no code path
14
+ * here that can render a second tick onto the strip.
15
15
  * - each pending converge gate as a card whose Approve button says what
16
16
  * approving actually does: it records a fact, and the next tick acts on it.
17
17
  * It does not release anything. That is the run gate, painted by the playhead
18
18
  * panel next door, and the card names its loop so the two can't be confused.
19
+ * When the gate fact names where approval happens (chant#2028) the card links
20
+ * it, under chant's own words — `approve at:`. When it doesn't, the card is
21
+ * exactly the card it was before that field existed.
22
+ * - the history, when a human opens it: the converge timeline chant#2029's
23
+ * `operator log` reads, ticks and gate resolutions interleaved. It grows from
24
+ * the strip and is fetched on the click, never on a timer — the strip is the
25
+ * at-a-glance line, the timeline is the thing you go and look at.
19
26
  */
20
27
  import { waitingFor } from "./run-playhead.js";
21
28
 
@@ -46,6 +53,35 @@ export const APPROVE_SEMANTICS =
46
53
  /** The past-tense half, for the toast right after the click. */
47
54
  export const APPROVED_SEMANTICS = "recorded; the next tick acts on it";
48
55
 
56
+ /** The words a gate's address is offered under — chant's own. `chant operator
57
+ * status`'s human render prints `approve at: <url>` beneath the pending gate, so
58
+ * the card labels the same fact the same way. */
59
+ export const APPROVE_AT = "approve at:";
60
+
61
+ /** The resolved half's label. Same field, chant#2028's other end
62
+ * (`GateResolutionRecord.url`): where the approval DID happen, so the tense
63
+ * changes — a resolution's link is a record, not an invitation. */
64
+ export const RESOLVED_AT = "resolved at:";
65
+
66
+ /**
67
+ * The address behold will put behind an `href`, or null.
68
+ *
69
+ * Re-checked here rather than only trusted from the server, exactly as
70
+ * APPROVE_SEMANTICS is re-stated above: an absolute http/https URL becomes a
71
+ * link, anything else reads as no address at all and the card renders without
72
+ * one. A `javascript:` string that reached a ledger must not become a link
73
+ * because a server was older than this file.
74
+ */
75
+ export function approvalLink(url) {
76
+ if (!url || typeof url !== "string") return null;
77
+ try {
78
+ const parsed = new URL(url);
79
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
49
85
  /** Which loop a card belongs to, spelled out. behold draws two gate cards and
50
86
  * they resolve through different commands with different effects. */
51
87
  export const CONVERGE_LOOP_LINE =
@@ -86,6 +122,8 @@ export function operatorRows(state, now = Date.now()) {
86
122
  log: null,
87
123
  at: null,
88
124
  ago: null,
125
+ tickId: null,
126
+ tick: null,
89
127
  lease: "free",
90
128
  leaseText: state.strip ? "not in the last status read" : "not read yet",
91
129
  pendingGates: 0,
@@ -98,6 +136,12 @@ export function operatorRows(state, now = Date.now()) {
98
136
  log: r.log,
99
137
  at: r.at,
100
138
  ago: r.at ? waitingFor(r.at, now) : null,
139
+ // chant#2027's tick id: whole (so the hover can quote it in full) and
140
+ // truncated (so the line stays a line). Both null on a chant that
141
+ // recorded none, and the row then reads exactly as it did before the
142
+ // field existed.
143
+ tickId: r.tickId || null,
144
+ tick: shortTickId(r.tickId),
101
145
  lease: r.lease,
102
146
  leaseText: leaseText(r),
103
147
  pendingGates: r.pendingGates,
@@ -105,6 +149,25 @@ export function operatorRows(state, now = Date.now()) {
105
149
  });
106
150
  }
107
151
 
152
+ /**
153
+ * How much of a tick id to show.
154
+ *
155
+ * chant mints one with `randomUUID()` (chant#2027), so the first block of a
156
+ * canonical UUID is already the part a human reads out and the part that
157
+ * distinguishes two ticks in the same ISO second — which is the whole reason
158
+ * the field exists. Anything shorter than the cut is shown whole rather than
159
+ * padded: behold never invents characters chant didn't write.
160
+ */
161
+ export const TICK_ID_CHARS = 8;
162
+
163
+ /** A tick id, short enough to sit on a strip line. Null in, null out — a chant
164
+ * older than 0.53.1 recorded no id, and the strip says nothing rather than
165
+ * showing a placeholder for an identity that doesn't exist. */
166
+ export function shortTickId(id) {
167
+ if (typeof id !== "string" || !id) return null;
168
+ return id.length > TICK_ID_CHARS ? id.slice(0, TICK_ID_CHARS) : id;
169
+ }
170
+
108
171
  /** The lease, in words. The holder is always named when there is one — "held"
109
172
  * without a holder is a fact nobody can act on. */
110
173
  export function leaseText(row) {
@@ -127,12 +190,18 @@ export function operatorHeadline(state) {
127
190
  /**
128
191
  * The one-tick caveat, said every time there is a tick to say it about.
129
192
  *
130
- * A reader must never take this strip for a shortened timeline: chant's
131
- * `operator status` keeps only the last ledger record and no CLI exposes the
132
- * rest (chant#2029), so one line is all there is, not one line behold chose.
193
+ * A reader must never take this strip for a shortened timeline: `chant operator
194
+ * status` keeps only the last ledger record per loop, so one line is all this
195
+ * read has, not one line behold chose. The rest of the history is a different
196
+ * command (chant#2029) and a different click — which the caveat now points at,
197
+ * because "this is not a timeline" without saying where the timeline IS would be
198
+ * the strip keeping a secret.
133
199
  */
134
200
  export const ONE_TICK_CAVEAT =
135
- "One tick per loop — chant's status surface exposes only the last one (chant#2029), so this is a strip, not a timeline.";
201
+ "One tick per loop — `chant operator status` answers with only the last one. The rest is its own read (chant#2029): open the history below.";
202
+
203
+ /** What the history disclosure is called, closed and open. */
204
+ export const HISTORY_LABEL = "converge history";
136
205
 
137
206
  /** The converge gate cards, from the same state. Each already carries the
138
207
  * delegated write it performs; nothing here is derived from a graph. */
@@ -140,6 +209,76 @@ export function convergeGates(state) {
140
209
  return (state && state.gates) || [];
141
210
  }
142
211
 
212
+ // ── the timeline (chant#2029) ────────────────────────────────────────────────
213
+
214
+ /** The rows of a loaded history, or [] for one that hasn't been read. */
215
+ export function timelineRows(history) {
216
+ return (history && history.data && history.data.entries) || [];
217
+ }
218
+
219
+ /**
220
+ * The history's own headline: how much of the ledger this is, and whether there
221
+ * is more behind it.
222
+ *
223
+ * The window is stated on screen rather than implied. behold asks for the newest
224
+ * n (never the whole ledger — it grows one line per tick forever), so a full
225
+ * window means "and older ticks before these", and a reader who isn't told that
226
+ * would read the oldest row as the beginning of history.
227
+ */
228
+ export function historyHeadline(history) {
229
+ const data = history && history.data;
230
+ if (!data) return null;
231
+ const n = data.entries.length;
232
+ const head = `${n} ${n === 1 ? "entry" : "entries"}`;
233
+ const window = data.window || {};
234
+ const bits = [`newest ${window.limit}`];
235
+ if (window.since) bits.push(`since ${window.since}`);
236
+ if (data.more) bits.push("older ticks before these");
237
+ return `${head} · ${bits.join(" · ")}`;
238
+ }
239
+
240
+ /**
241
+ * The unreadable-line count, in words, or null when every line read.
242
+ *
243
+ * chant counts the ledger lines it could not parse rather than throwing, so a
244
+ * corrupted ledger renders a SHORTER timeline. Surfacing the count is the
245
+ * difference between "3 lines of this ledger are unreadable" and a quiet gap the
246
+ * reader takes for a quiet loop.
247
+ */
248
+ export function historyMalformedNote(history) {
249
+ const m = (history && history.data && history.data.malformed) || null;
250
+ if (!m) return null;
251
+ const total = (m.converge || 0) + (m.gates || 0);
252
+ if (!total) return null;
253
+ return (
254
+ `${total} ledger line${total === 1 ? "" : "s"} were unreadable and are missing from this timeline ` +
255
+ `(converge: ${m.converge || 0}, gates: ${m.gates || 0}).`
256
+ );
257
+ }
258
+
259
+ /**
260
+ * One timeline row, in words — chant's own `operator log` render is the model.
261
+ *
262
+ * A tick reads as its op, its id and the log line chant wrote, verbatim; a
263
+ * resolution reads as who resolved which gate. Nothing here rewords a fact:
264
+ * behold's job on this surface is ordering and legibility, not narration.
265
+ */
266
+ export function timelineText(row) {
267
+ if (!row) return "";
268
+ if (row.kind === "gate-resolution") {
269
+ return `gate resolved · ${row.op}/${row.gate} by ${row.resolvedBy}`;
270
+ }
271
+ // Bracketed, the way chant's own `operator log` render prints it, and cut by
272
+ // the strip's own {@link shortTickId} so one tick reads the same in both.
273
+ const id = shortTickId(row.id) ? ` [${shortTickId(row.id)}]` : "";
274
+ return `${row.op}${row.env ? `@${row.env}` : ""}${id}`;
275
+ }
276
+
277
+ /** One gated outcome within a tick, in chant's own `operator log` words. */
278
+ export function gatedText(gate) {
279
+ return `gated ${gate.rule} → ${gate.op || "?"} gate "${gate.gate}"`;
280
+ }
281
+
143
282
  // ── the DOM half ─────────────────────────────────────────────────────────────
144
283
 
145
284
  function el(tag, cls, text) {
@@ -149,13 +288,33 @@ function el(tag, cls, text) {
149
288
  return n;
150
289
  }
151
290
 
291
+ /** An `approve at: <url>` link, or null for a fact carrying no address behold
292
+ * will link. Never a placeholder: the absent case renders nothing at all. */
293
+ function approveAtLink(url, label = APPROVE_AT) {
294
+ const href = approvalLink(url);
295
+ if (!href) return null;
296
+ const a = el("a", "operator-url", `${label} ${href}`);
297
+ a.href = href;
298
+ a.target = "_blank";
299
+ a.rel = "noreferrer noopener";
300
+ a.title = `${href} — the address the gate fact itself records (chant#2028). behold never invents this link.`;
301
+ return a;
302
+ }
303
+
152
304
  /**
153
305
  * Render the operator strip into `host` (cleared first — the whole subtree is
154
306
  * rebuilt per event, exactly as renderPlayhead does).
155
307
  *
156
- * `actions.approve(op, gate)` performs the delegated `chant approve`.
308
+ * `actions.approve(op, gate)` performs the delegated `chant approve`;
309
+ * `actions.history()` toggles the timeline below the strip and is what triggers
310
+ * its one read. Omit it (a static export has no server to ask) and no history
311
+ * affordance is drawn at all.
312
+ *
313
+ * `history` is the caller's own `{open, loading, error, data}` — this module
314
+ * decides what the panel SAYS, app.js owns the fetch and the open/closed bit,
315
+ * the same split every other panel here keeps.
157
316
  */
158
- export function renderOperator(host, state, actions = {}) {
317
+ export function renderOperator(host, state, actions = {}, history = null) {
159
318
  host.textContent = "";
160
319
  if (!hasOperator(state)) return host;
161
320
 
@@ -178,8 +337,12 @@ export function renderOperator(host, state, actions = {}) {
178
337
  body.appendChild(head);
179
338
  // chant's own log line, verbatim — behold re-words nothing about a tick.
180
339
  body.appendChild(el("div", "operator-log", r.log || "no tick recorded yet"));
181
- const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}` : "never ticked";
182
- body.appendChild(el("div", "operator-meta", `${when} · ${r.leaseText}`));
340
+ // `· tick <id>` only when chant wrote one (chant#2027) — a tick becomes a
341
+ // thing you can point at, and an older chant's line is unchanged.
342
+ const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}${r.tick ? ` · tick ${r.tick}` : ""}` : "never ticked";
343
+ const meta = el("div", "operator-meta", `${when} · ${r.leaseText}`);
344
+ if (r.tick) meta.title = `tick id ${r.tickId || r.tick}`;
345
+ body.appendChild(meta);
183
346
  row.appendChild(body);
184
347
 
185
348
  if (r.pendingGates) {
@@ -190,11 +353,76 @@ export function renderOperator(host, state, actions = {}) {
190
353
  host.appendChild(list);
191
354
 
192
355
  if (rows.some((r) => r.at)) host.appendChild(el("div", "operator-caveat", ONE_TICK_CAVEAT));
356
+ if (actions.history) host.appendChild(renderHistory(history, actions));
193
357
 
194
358
  for (const gate of convergeGates(state)) host.appendChild(renderConvergeGateCard(gate, actions));
195
359
  return host;
196
360
  }
197
361
 
362
+ /**
363
+ * The history disclosure and, when it is open, the timeline.
364
+ *
365
+ * Closed is the default and costs nothing: the read happens on the click that
366
+ * opens it, and there is no interval anywhere on this path. The strip above ages
367
+ * on its own — a tick lands whether or not anyone is looking — but a ledger's
368
+ * past does not change while you read it, so re-fetching it on a timer would be
369
+ * spending a chant process per interval to be told the same thing.
370
+ */
371
+ function renderHistory(history, actions) {
372
+ const box = el("div", "operator-history");
373
+ const open = !!(history && history.open);
374
+ const toggle = el("button", "operator-history-toggle", `${open ? "▾" : "▸"} ${HISTORY_LABEL}`);
375
+ toggle.title =
376
+ "The converge timeline: ticks and gate resolutions from the chant/lifecycle ledger " +
377
+ "(chant operator log, chant#2029). Read when you open it — behold never polls the history.";
378
+ toggle.addEventListener("click", () => actions.history());
379
+ box.appendChild(toggle);
380
+ if (!open) return box;
381
+
382
+ if (history.error) {
383
+ box.appendChild(el("div", "run-lost", `Converge history unavailable — ${history.error}`));
384
+ return box;
385
+ }
386
+ if (history.loading || !history.data) {
387
+ box.appendChild(el("div", "operator-meta", "reading the converge ledger…"));
388
+ return box;
389
+ }
390
+
391
+ box.appendChild(el("div", "operator-history-head", historyHeadline(history)));
392
+ const malformed = historyMalformedNote(history);
393
+ if (malformed) box.appendChild(el("div", "run-lost", malformed));
394
+
395
+ const rows = timelineRows(history);
396
+ if (!rows.length) {
397
+ box.appendChild(el("div", "operator-meta", "No converge ticks recorded in this window."));
398
+ return box;
399
+ }
400
+
401
+ const list = el("div", "operator-timeline");
402
+ for (const row of rows) {
403
+ // Oldest first, exactly as chant merged them — a timeline reads forward.
404
+ const entry = el("div", `operator-entry operator-entry-${row.kind}`);
405
+ entry.appendChild(el("div", "operator-meta", `${row.at} · ${timelineText(row)}`));
406
+ if (row.kind === "tick") {
407
+ // chant's own log line, verbatim — the strip's rule, on every row.
408
+ entry.appendChild(el("div", "operator-log", row.log));
409
+ for (const gate of row.gated) {
410
+ const line = el("div", "operator-gated", gatedText(gate));
411
+ const link = approveAtLink(gate.url);
412
+ if (link) line.appendChild(link);
413
+ entry.appendChild(line);
414
+ }
415
+ } else {
416
+ if (row.note) entry.appendChild(el("div", "operator-log", row.note));
417
+ const link = approveAtLink(row.url, RESOLVED_AT);
418
+ if (link) entry.appendChild(link);
419
+ }
420
+ list.appendChild(entry);
421
+ }
422
+ box.appendChild(list);
423
+ return box;
424
+ }
425
+
198
426
  /**
199
427
  * A pending converge gate.
200
428
  *
@@ -205,9 +433,12 @@ export function renderOperator(host, state, actions = {}) {
205
433
  * own gate-ledger doc, and what `chant approve` itself prints;
206
434
  * - the exact command the button delegates to.
207
435
  *
208
- * And one thing it deliberately does not render: a link. A pending gate fact
209
- * carries no URL (chant#2028), and a placeholder link would invent a review
210
- * surface that does not exist.
436
+ * And the fourth, since chant#2028: where approval happens, when the gate fact
437
+ * says so. `approve at: <url>` — chant's own words from `operator status`'s
438
+ * human render — linking the PR/MR the gated tick knew about. A fact with no
439
+ * address renders no link and no placeholder for one: the url is optional by
440
+ * design (a local tick with no PR behind it genuinely has none), and inventing a
441
+ * review surface would be worse than the shell command it replaces.
211
442
  */
212
443
  export function renderConvergeGateCard(gate, actions = {}) {
213
444
  const box = el("div", "run-gate operator-gate");
@@ -224,6 +455,8 @@ export function renderConvergeGateCard(gate, actions = {}) {
224
455
  ),
225
456
  );
226
457
  box.appendChild(el("div", "operator-semantics", gate.semantics || APPROVE_SEMANTICS));
458
+ const at = approveAtLink(gate.url);
459
+ if (at) box.appendChild(at);
227
460
 
228
461
  const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
229
462
  const approve = el("button", "approve", "Record approval");