@intentius/behold 0.22.0 → 0.24.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.
@@ -0,0 +1,138 @@
1
+ // #490: a terragucci estate painted from its reports. Pure: app.js fetches
2
+ // /api/terragucci, then stamps cards and fills the panel tab from what these
3
+ // functions return.
4
+ //
5
+ // Everything here is dated. A mark says what a run found, when that run
6
+ // finished and how long ago that is, and links the run's report and job. No
7
+ // mark is a fill and none says "live": a card the reports say nothing about is
8
+ // left as it is, which is not the same as fine. A waiting wave offers only the
9
+ // line a person runs at their shell; behold has no approve button for it.
10
+
11
+ /** "4h ago", "3d ago", "just now". */
12
+ export function ago(iso, now = new Date()) {
13
+ const s = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 1000));
14
+ if (s < 60) return "just now";
15
+ const m = Math.round(s / 60);
16
+ if (m < 60) return `${m}m ago`;
17
+ const h = Math.round(m / 60);
18
+ if (h < 48) return `${h}h ago`;
19
+ return `${Math.round(h / 24)}d ago`;
20
+ }
21
+
22
+ /** The run's finish as a clock time, with the date when it is not today, in UTC so every viewer reads the same words. */
23
+ export function clock(iso, now = new Date()) {
24
+ const d = new Date(iso);
25
+ const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
26
+ const sameDay = d.toISOString().slice(0, 10) === now.toISOString().slice(0, 10);
27
+ return sameDay ? `${hhmm} UTC` : `${d.toISOString().slice(0, 10)} ${hhmm} UTC`;
28
+ }
29
+
30
+ const STAGE_WORDS = { "tf-drift": "drift check", "tf-plan": "plan", "tf-apply": "apply" };
31
+
32
+ /** Which run, in words: "drift check 06:04 UTC, 4h ago", "plan on PR #12 09:00 UTC, 1h ago". */
33
+ export function runWords(run, now = new Date()) {
34
+ const what = run.stage === "tf-apply" && run.wave !== undefined ? `apply wave ${run.wave}` : STAGE_WORDS[run.stage] || run.stage;
35
+ const pr = run.pull_request ? ` on PR #${run.pull_request}` : "";
36
+ return `${what}${pr} ${clock(run.finished, now)}, ${ago(run.finished, now)}`;
37
+ }
38
+
39
+ /** One mark as a sentence: "drift found 06:04 UTC, 4h ago: deleted outside Terraform". */
40
+ export function markLine(mark, now = new Date()) {
41
+ const lead = mark.verdict === "drift" ? "drift found" : mark.verdict === "wave" ? `wave ${mark.run.wave} waiting` : `plan${mark.run.pull_request ? ` on PR #${mark.run.pull_request}` : ""}`;
42
+ const attrs = mark.attributes && mark.attributes.length ? ` (${mark.attributes.join(", ")})` : "";
43
+ const many = mark.addresses.length > 1 ? ` · ${mark.addresses.length} instances` : "";
44
+ return `${lead} ${clock(mark.run.finished, now)}, ${ago(mark.run.finished, now)}: ${mark.words}${attrs}${many}`;
45
+ }
46
+
47
+ /** The corner glyph of a marked card: drift outranks a wave, a wave outranks a plan. */
48
+ export function cornerOf(marks) {
49
+ if (marks.some((m) => m.verdict === "drift")) return { text: "⚠ drift", tone: "drift" };
50
+ if (marks.some((m) => m.verdict === "wave")) return { text: "⏸ wave", tone: "wave" };
51
+ return { text: "~ plan", tone: "plan" };
52
+ }
53
+
54
+ /** The address a key opens at: this server's file route, or a relative path in an export. */
55
+ export function hrefOf(answer, key) {
56
+ return `${answer.files || ""}${answer.files && answer.files.includes("?") ? encodeURIComponent(key) : key}`;
57
+ }
58
+
59
+ /** The newest run any mark or root names: what the legend dates the picture by. */
60
+ export function newestRun(answer) {
61
+ let best = null;
62
+ const see = (run) => {
63
+ if (run && (!best || run.finished > best.finished)) best = run;
64
+ };
65
+ for (const r of answer.roots || []) for (const k of ["plan", "drift", "apply"]) see(r[k] && r[k].run);
66
+ return best;
67
+ }
68
+
69
+ /** The line under the tab's title: whose reports, how old, how checked. Never "live". `title` carries the long form. */
70
+ export function legend(answer, now = new Date()) {
71
+ const newest = newestRun(answer);
72
+ const checked = answer.validation && answer.validation.by === "schema" ? `checked against terragucci ${answer.validation.terragucci}'s schemas` : "checked structurally; install @intentius/terragucci to check against its schemas";
73
+ return {
74
+ text: `terragucci reports of ${answer.project}, not live${newest ? ` · newest run ${ago(newest.finished, now)}` : " · no runs for these roots yet"}`,
75
+ title: [newest ? `newest run: ${runWords(newest, now)}` : "", `${answer.reports} report${answer.reports === 1 ? "" : "s"} read from ${answer.source}`, checked].filter(Boolean).join("\n"),
76
+ };
77
+ }
78
+
79
+ /** A root's cell for one stage: what its newest run of that stage found, or that there is none. Short, with the clock in `title`. */
80
+ export function stageCell(verdict, stage, now = new Date()) {
81
+ if (!verdict) return { text: "no report", tone: "none", title: `no ${STAGE_WORDS[stage]} report holds this root` };
82
+ const title = [runWords(verdict.run, now), verdict.error].filter(Boolean).join("\n");
83
+ const age = ago(verdict.run.finished, now);
84
+ if (verdict.status === "failed") return { text: `failed, ${age}`, tone: "failed", title };
85
+ if (stage === "tf-apply") {
86
+ if (verdict.approval === "waiting") return { text: `wave ${verdict.run.wave} waiting, ${age}`, tone: "wave", title };
87
+ return { text: `wave ${verdict.run.wave} ${verdict.applied ? "applied" : verdict.approval || "ran"}, ${age}`, tone: "ok", title };
88
+ }
89
+ if (verdict.changes === 0) return { text: `${stage === "tf-drift" ? "none" : "no changes"}, ${age}`, tone: "ok", title };
90
+ const noun = stage === "tf-drift" ? "drifted" : `change${verdict.changes === 1 ? "" : "s"}`;
91
+ const pr = verdict.run.pull_request ? ` (PR #${verdict.run.pull_request})` : "";
92
+ return { text: `${verdict.changes} ${noun}${pr}, ${age}`, tone: stage === "tf-drift" ? "drift" : "plan", title };
93
+ }
94
+
95
+ /** The roots table, drift first: the rows that say something come before the quiet ones. */
96
+ export function rootRows(answer, now = new Date()) {
97
+ const rank = (r) => (r.drift && (r.drift.status === "failed" || r.drift.changes > 0) ? 0 : r.apply && r.apply.approval === "waiting" ? 1 : r.plan && r.plan.changes > 0 ? 2 : 3);
98
+ return [...(answer.roots || [])]
99
+ .sort((a, b) => rank(a) - rank(b) || (a.path < b.path ? -1 : 1))
100
+ .map((r) => ({
101
+ path: r.path,
102
+ name: r.name,
103
+ member: r.member,
104
+ drift: { ...stageCell(r.drift, "tf-drift", now), ...(r.drift ? { href: hrefOf(answer, r.drift.run.report) } : {}) },
105
+ plan: { ...stageCell(r.plan, "tf-plan", now), ...(r.plan ? { href: hrefOf(answer, r.plan.run.report) } : {}) },
106
+ apply: { ...stageCell(r.apply, "tf-apply", now), ...(r.apply ? { href: hrefOf(answer, r.apply.run.report) } : {}) },
107
+ }));
108
+ }
109
+
110
+ /** A waiting wave as a gate card: what it holds and destroys, how long it has waited, and the line to run. No button. */
111
+ export function waveCards(answer, now = new Date()) {
112
+ return (answer.waiting || []).map((w) => ({
113
+ title: `wave ${w.wave} waits for an approval`,
114
+ facts: [
115
+ `since ${clock(w.since, now)}, ${ago(w.since, now)}`,
116
+ `${w.roots.length} root${w.roots.length === 1 ? "" : "s"}`,
117
+ `commit ${w.commit.slice(0, 12)}`,
118
+ w.set_digest ? `plan ${w.set_digest.replace(/^jcs1-sha256:/, "").slice(0, 12)}` : null,
119
+ ]
120
+ .filter(Boolean)
121
+ .join(" · "),
122
+ destroys: w.destroys,
123
+ command: w.command,
124
+ note: "An approval is a signed git record bound to this plan digest: run the line at your shell. behold does not approve.",
125
+ report: hrefOf(answer, w.report),
126
+ ...(w.job_url ? { job: w.job_url } : {}),
127
+ }));
128
+ }
129
+
130
+ /** terragucci's own counts, as its estate page shows them, with the page's link. */
131
+ export function estateLine(answer) {
132
+ if (!answer.estate) return null;
133
+ const e = answer.estate;
134
+ return {
135
+ text: `terragucci's estate page: ${e.drifted} root${e.drifted === 1 ? "" : "s"} drifted · ${e.failed} failed · ${e.waiting} wave${e.waiting === 1 ? "" : "s"} waiting`,
136
+ href: hrefOf(answer, e.html),
137
+ };
138
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ago, clock, cornerOf, estateLine, hrefOf, legend, markLine, rootRows, runWords, stageCell, waveCards } from "./terragucci.js";
3
+
4
+ const NOW = new Date("2026-10-08T10:04:12Z");
5
+ const run = (over = {}) => ({ stage: "tf-drift", finished: "2026-10-08T06:04:12.000Z", commit: "5e6f7a8b9c0d", report: "p/r/tf-drift/report.html", ...over });
6
+
7
+ describe("terragucci marks are dated, never live (#490)", () => {
8
+ it("says how old a run is", () => {
9
+ expect(ago("2026-10-08T06:04:12Z", NOW)).toBe("4h ago");
10
+ expect(ago("2026-10-08T10:03:50Z", NOW)).toBe("just now");
11
+ expect(ago("2026-10-08T09:34:12Z", NOW)).toBe("30m ago");
12
+ expect(ago("2026-10-04T10:04:12Z", NOW)).toBe("4d ago");
13
+ });
14
+
15
+ it("names the clock in UTC, with the date when it is not today", () => {
16
+ expect(clock("2026-10-08T06:04:12Z", NOW)).toBe("06:04 UTC");
17
+ expect(clock("2026-10-07T06:04:00Z", NOW)).toBe("2026-10-07 06:04 UTC");
18
+ });
19
+
20
+ it("reads a drift mark as terragucci would say it", () => {
21
+ const m = { verdict: "drift", action: "delete", words: "deleted outside Terraform", addresses: ["module.service.aws_sqs_queue.jobs"], attributes: [], run: run() };
22
+ expect(markLine(m, NOW)).toBe("drift found 06:04 UTC, 4h ago: deleted outside Terraform");
23
+ });
24
+
25
+ it("names a plan's pull request, its attributes and its instances", () => {
26
+ const m = { verdict: "plan", action: "replace", words: "would replace", addresses: ["x.y[0]", "x.y[1]"], attributes: ["hash_key"], run: run({ stage: "tf-plan", pull_request: "11", finished: "2026-10-08T08:00:30Z" }) };
27
+ expect(markLine(m, NOW)).toBe("plan on PR #11 08:00 UTC, 2h ago: would replace (hash_key) · 2 instances");
28
+ expect(runWords(m.run, NOW)).toBe("plan on PR #11 08:00 UTC, 2h ago");
29
+ });
30
+
31
+ it("puts drift ahead of a waiting wave and a wave ahead of a plan in the corner", () => {
32
+ const plan = { verdict: "plan" };
33
+ expect(cornerOf([plan, { verdict: "drift" }]).tone).toBe("drift");
34
+ expect(cornerOf([plan, { verdict: "wave" }]).tone).toBe("wave");
35
+ expect(cornerOf([plan]).tone).toBe("plan");
36
+ });
37
+
38
+ it("tells 'no report' from 'no changes', and a failure from both", () => {
39
+ expect(stageCell(undefined, "tf-plan", NOW)).toMatchObject({ text: "no report", tone: "none" });
40
+ expect(stageCell({ run: run(), status: "planned", changes: 0 }, "tf-drift", NOW)).toMatchObject({ text: "none, 4h ago", tone: "ok" });
41
+ expect(stageCell({ run: run(), status: "failed", error: "AccessDenied", changes: 0 }, "tf-drift", NOW)).toMatchObject({ text: "failed, 4h ago", tone: "failed", title: expect.stringContaining("AccessDenied") });
42
+ expect(stageCell({ run: run({ stage: "tf-apply", wave: 2 }), status: "planned", changes: 1, approval: "waiting" }, "tf-apply", NOW)).toMatchObject({ text: "wave 2 waiting, 4h ago", tone: "wave" });
43
+ });
44
+
45
+ it("says where the picture comes from and that it is not live", () => {
46
+ const answer = { project: "github.com/acme/shop", source: "/b", reports: 5, validation: { by: "structural" }, roots: [{ drift: { run: run() } }] };
47
+ const l = legend(answer, NOW);
48
+ expect(l.text).toBe("terragucci reports of github.com/acme/shop, not live · newest run 4h ago");
49
+ expect(l.title).toContain("5 reports read from /b");
50
+ expect(l.title).toContain("install @intentius/terragucci");
51
+ expect(l.text).not.toMatch(/\blive\b(?<!not live)/);
52
+ });
53
+
54
+ it("sorts the roots that say something first", () => {
55
+ const answer = {
56
+ files: "/api/terragucci/file?key=",
57
+ roots: [
58
+ { path: "a", drift: { run: run(), status: "planned", changes: 0 } },
59
+ { path: "b", drift: { run: run(), status: "planned", changes: 1 } },
60
+ { path: "c", apply: { run: run({ stage: "tf-apply", wave: 2 }), status: "planned", changes: 0, approval: "waiting" } },
61
+ ],
62
+ };
63
+ const rows = rootRows(answer, NOW);
64
+ expect(rows.map((r) => r.path)).toEqual(["b", "c", "a"]);
65
+ expect(rows[0].drift.href).toBe("/api/terragucci/file?key=p%2Fr%2Ftf-drift%2Freport.html");
66
+ });
67
+ });
68
+
69
+ describe("a waiting wave is a gate card with a line to copy, never a button (#490)", () => {
70
+ const answer = {
71
+ files: "/api/terragucci/file?key=",
72
+ waiting: [{ wave: 2, commit: "d3e4f5a6b7c8d9e0", since: "2026-10-08T09:31:00Z", roots: ["envs/staging/email", "envs/staging/orders"], set_digest: "jcs1-sha256:2e0fba16ce38925adf41", destroys: ["envs/staging/email: x (delete)"], report: "r/report.html", command: "npx terragucci approve wave-2 --plan jcs1-sha256:2e0fba16ce38925adf41", from: "estate" }],
73
+ };
74
+ it("carries what the wave holds and destroys, and how long it has waited", () => {
75
+ const [c] = waveCards(answer, NOW);
76
+ expect(c.title).toBe("wave 2 waits for an approval");
77
+ expect(c.facts).toBe("since 09:31 UTC, 33m ago · 2 roots · commit d3e4f5a6b7c8 · plan 2e0fba16ce38");
78
+ expect(c.destroys).toEqual(["envs/staging/email: x (delete)"]);
79
+ expect(c.command).toBe("npx terragucci approve wave-2 --plan jcs1-sha256:2e0fba16ce38925adf41");
80
+ expect(c).not.toHaveProperty("button");
81
+ expect(c.note).toContain("behold does not approve");
82
+ });
83
+
84
+ it("links a key through the server, or as a relative path in an export", () => {
85
+ expect(hrefOf({ files: "/api/terragucci/file?key=" }, "a/b.html")).toBe("/api/terragucci/file?key=a%2Fb.html");
86
+ expect(hrefOf({ files: "../../" }, "a/b.html")).toBe("../../a/b.html");
87
+ });
88
+
89
+ it("quotes terragucci's estate page, and links it", () => {
90
+ expect(estateLine({ files: "../../", estate: { drifted: 1, failed: 1, waiting: 1, html: "estate.html" } })).toEqual({
91
+ text: "terragucci's estate page: 1 root drifted · 1 failed · 1 wave waiting",
92
+ href: "../../estate.html",
93
+ });
94
+ expect(estateLine({})).toBeNull();
95
+ });
96
+ });
package/web/why.js ADDED
@@ -0,0 +1,107 @@
1
+ // #471: the "why" section of the inspect pane, for a member or a card in a
2
+ // declared workspace. Pure: app.js fetches /api/workspace/why when someone
3
+ // clicks "read why" and paints what whyView returns.
4
+ //
5
+ // Everything here is chant's answer (`chant workspace graph --intent <region>
6
+ // --json`, its `why`), put into words. The decisions are chant's ranking, in
7
+ // chant's order; behold neither re-ranks nor filters them. A proposed decision
8
+ // links to its review in hud when behold was started with --hud, and is named
9
+ // by its id otherwise.
10
+
11
+ /** The query for a member or a node. A node wins: it names a smaller region. */
12
+ export function whyQuery({ member = null, node = null } = {}) {
13
+ const q = new URLSearchParams();
14
+ if (node) q.set("node", node);
15
+ else if (member) q.set("member", member);
16
+ return `/api/workspace/why?${q}`;
17
+ }
18
+
19
+ /** Seconds, for "reading… 12 s" and "read in 4.1 s". */
20
+ export function seconds(ms) {
21
+ if (!(ms >= 0)) return "";
22
+ return ms < 10_000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms / 1000)} s`;
23
+ }
24
+
25
+ const RELEVANCE = {
26
+ carried: "carried out by the commits or runs that wrote these lines",
27
+ path: "constrains this path",
28
+ contract: "constrains it through a contract",
29
+ issue: "names an issue this work closed",
30
+ member: "constrains the whole member",
31
+ related: "related through supersession or an older commit",
32
+ };
33
+
34
+ const short = (sha) => String(sha || "").slice(0, 8);
35
+
36
+ /**
37
+ * The section's content for an answer from /api/workspace/why: a headline,
38
+ * then decisions, runs, commits and gaps as rows of plain strings (and an
39
+ * href for a decision under review). `refusal` instead when chant couldn't
40
+ * answer.
41
+ */
42
+ export function whyView(answer) {
43
+ if (!answer) return { refusal: { error: "no answer", remedy: "" } };
44
+ if (answer.refusal) return { refusal: answer.refusal, footer: footer(answer) };
45
+ if (answer.error) return { refusal: { error: answer.error, remedy: answer.remedy || "" } };
46
+ if (!answer.answered) {
47
+ return {
48
+ headline: `chant ${answer.chant || "(unknown version)"} reads the intent graph but writes no "why"; it needs chant 0.102.0 or newer in the workspace root.`,
49
+ decisions: [],
50
+ runs: [],
51
+ commits: [],
52
+ gaps: [],
53
+ footer: footer(answer),
54
+ };
55
+ }
56
+ const where = answer.path ? `${answer.path}${answer.type === "dir" ? "/" : ""}` : answer.region;
57
+ const current = answer.decisions.filter((d) => d.current).length;
58
+ let headline;
59
+ if (answer.decisions.length === 0) headline = `No decision covers ${where}.`;
60
+ else headline = `${answer.decisions.length} decision${answer.decisions.length === 1 ? "" : "s"} cover${answer.decisions.length === 1 ? "s" : ""} ${where}${current !== answer.decisions.length ? ` (${current} current)` : ""}.`;
61
+ if (!answer.explained && answer.decisions.length) headline += " None of them is current and accounts for it.";
62
+
63
+ const decisions = answer.decisions.map((d) => ({
64
+ id: d.id,
65
+ title: d.title || "",
66
+ meta: [d.state || "no state", RELEVANCE[d.relevance] || d.relevance, d.current ? null : "superseded", d.lines ? `${d.lines} line${d.lines === 1 ? "" : "s"} as its own work` : null, d.decidedBy ? `decided by ${d.decidedBy}${d.decidedOn ? ` on ${d.decidedOn}` : ""}` : null]
67
+ .filter(Boolean)
68
+ .join(" · "),
69
+ href: d.review || null,
70
+ note: d.state === "proposed" && !d.review ? "proposed; under review in hud (behold has no --hud address to link)" : null,
71
+ }));
72
+
73
+ const runs = answer.runs.map((r) => ({
74
+ id: r.id,
75
+ meta: [
76
+ r.by ? `by ${r.by}` : null,
77
+ r.harness ? (r.model && r.model !== "none" ? `${r.harness} · ${r.model}` : r.harness) : null,
78
+ r.outcome,
79
+ r.lines ? `${r.lines} line${r.lines === 1 ? "" : "s"}` : null,
80
+ r.unit ? `work ${r.unit}` : null,
81
+ r.commits.length ? `commit${r.commits.length === 1 ? "" : "s"} ${r.commits.map(short).join(", ")}` : null,
82
+ r.decisions.length ? `carried out ${r.decisions.join(", ")}` : null,
83
+ ]
84
+ .filter(Boolean)
85
+ .join(" · "),
86
+ when: r.startedAt || "",
87
+ }));
88
+
89
+ const commits = answer.commits.map((c) => ({
90
+ sha: short(c.sha),
91
+ subject: c.subject || "",
92
+ meta: [c.author, c.date ? c.date.slice(0, 10) : null, c.pullRequest ? `#${c.pullRequest}` : null, c.run ? `run ${c.run}` : null].filter(Boolean).join(" · "),
93
+ }));
94
+ const more = answer.commitsTotal > answer.commits.length ? answer.commitsTotal - answer.commits.length : 0;
95
+ const gaps = answer.gaps.map((g) => g.message || g.code);
96
+ if (answer.uncommittedLines) gaps.unshift(`${answer.uncommittedLines} line${answer.uncommittedLines === 1 ? " isn't" : "s aren't"} committed yet.`);
97
+ return { headline, decisions, runs, commits, more, gaps, footer: footer(answer) };
98
+ }
99
+
100
+ function footer(answer) {
101
+ const parts = [];
102
+ if (typeof answer.ms === "number") parts.push(`read in ${seconds(answer.ms)}`);
103
+ if (answer.chant) parts.push(`chant ${answer.chant}`);
104
+ const region = answer.region ? String(answer.region).replace(/^region:/, "") : null;
105
+ if (region) parts.push(`chant workspace graph --intent ${region}`);
106
+ return parts.join(" · ");
107
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { seconds, whyQuery, whyView } from "./why.js";
3
+
4
+ const answer = (over = {}) => ({
5
+ workspace: true,
6
+ member: "delivery",
7
+ ms: 4120,
8
+ chant: "0.102.0",
9
+ region: "region:delivery/src/app.ts",
10
+ path: "delivery/src/app.ts",
11
+ type: "file",
12
+ answered: true,
13
+ explained: true,
14
+ decisions: [
15
+ { id: "fix-002", title: "Where kept records wait", state: "proposed", relevance: "member", current: true, closed: false, lines: 0, decidedBy: null, decidedOn: null, review: "http://hud.test/decisions#fix-002" },
16
+ { id: "fix-001", title: "How the app is deployed", state: "decided", relevance: "member", current: true, closed: false, lines: 0, decidedBy: "lex00", decidedOn: "2026-09-24", review: null },
17
+ ],
18
+ runs: [{ id: "conformance-run", lines: 4, commits: ["6b3865dd9936437b58ca8bf84db0ec5264d85f7b"], unit: null, by: "conformance", harness: "conformance", model: "none", outcome: "done", startedAt: "2026-01-01T00:00:00Z", endedAt: null, decisions: [] }],
19
+ commits: [{ sha: "6b3865dd9936437b58ca8bf84db0ec5264d85f7b", subject: "the reader conformance workspace", date: "2026-10-07T23:45:37-06:00", author: "chant", pullRequest: null, run: "conformance-run" }],
20
+ commitsTotal: 1,
21
+ uncommittedLines: 0,
22
+ gaps: [],
23
+ ...over,
24
+ });
25
+
26
+ describe("whyQuery (#471)", () => {
27
+ it("asks about the node when there is one, the member otherwise", () => {
28
+ expect(whyQuery({ member: "delivery", node: "delivery/appService" })).toBe("/api/workspace/why?node=delivery%2FappService");
29
+ expect(whyQuery({ member: "delivery" })).toBe("/api/workspace/why?member=delivery");
30
+ });
31
+ });
32
+
33
+ describe("whyView (#471)", () => {
34
+ it("names the decisions in chant's order, linking a proposed one to hud", () => {
35
+ const v = whyView(answer());
36
+ expect(v.headline).toBe("2 decisions cover delivery/src/app.ts.");
37
+ expect(v.decisions.map((d) => d.id)).toEqual(["fix-002", "fix-001"]);
38
+ expect(v.decisions[0].href).toBe("http://hud.test/decisions#fix-002");
39
+ expect(v.decisions[1].href).toBeNull();
40
+ expect(v.decisions[1].meta).toContain("decided by lex00 on 2026-09-24");
41
+ expect(v.runs[0].meta).toContain("commit 6b3865dd");
42
+ expect(v.commits[0]).toMatchObject({ sha: "6b3865dd", subject: "the reader conformance workspace" });
43
+ expect(v.footer).toBe("read in 4.1 s · chant 0.102.0 · chant workspace graph --intent delivery/src/app.ts");
44
+ });
45
+
46
+ it("without hud, a proposed decision is its id and says where it is reviewed", () => {
47
+ const d = answer().decisions[0];
48
+ const v = whyView(answer({ decisions: [{ ...d, review: null }] }));
49
+ expect(v.decisions[0].href).toBeNull();
50
+ expect(v.decisions[0].note).toContain("under review in hud");
51
+ });
52
+
53
+ it("says when nothing covers it, and what chant couldn't account for", () => {
54
+ const v = whyView(answer({ decisions: [], explained: false, uncommittedLines: 2, gaps: [{ code: "intent-why-no-decision", message: "no current decision governs it" }] }));
55
+ expect(v.headline).toBe("No decision covers delivery/src/app.ts.");
56
+ expect(v.gaps).toEqual(["2 lines aren't committed yet.", "no current decision governs it"]);
57
+ });
58
+
59
+ it("an old chant, a refusal and a missing workspace each say so in words", () => {
60
+ expect(whyView(answer({ answered: false, chant: "0.95.0" })).headline).toContain("needs chant 0.102.0");
61
+ expect(whyView({ workspace: true, refusal: { error: "nope", remedy: "do this" }, region: "delivery/x", ms: 600 }).refusal.error).toBe("nope");
62
+ expect(whyView({ workspace: false, error: "not a workspace" }).refusal.error).toBe("not a workspace");
63
+ });
64
+
65
+ it("counts seconds the way the reading line does", () => {
66
+ expect(seconds(950)).toBe("0.9 s");
67
+ expect(seconds(29_400)).toBe("29 s");
68
+ });
69
+ });