@intentius/chant 0.53.0 → 0.53.1

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/src/cli/main.ts CHANGED
@@ -30,7 +30,7 @@ import { runGraph } from "./handlers/graph";
30
30
  import { runExplain } from "./handlers/explain";
31
31
  import { runSearch } from "./handlers/search";
32
32
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
33
- import { runOperator, runOperatorStatus, runApprove } from "./handlers/operator";
33
+ import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
34
34
  import { runEmulator } from "./handlers/emulator";
35
35
  import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
36
36
  import type { LexiconPlugin } from "../lexicon";
@@ -388,6 +388,16 @@ export function parseArgs(args: string[]): ParsedArgs {
388
388
  result.once = true;
389
389
  } else if (arg === "--note") {
390
390
  result.note = args[++i];
391
+ } else if (arg === "--url") {
392
+ result.url = args[++i];
393
+ } else if (arg === "--op") {
394
+ result.op = args[++i];
395
+ } else if (arg === "--since") {
396
+ result.since = args[++i];
397
+ } else if (arg === "--limit") {
398
+ // Parsed here, validated by the handler — `parseArgs` reports shape, not
399
+ // policy, the same split every other value flag here uses.
400
+ result.limit = Number(args[++i]);
391
401
  } else if (arg.startsWith("--")) {
392
402
  // chant #1127 — every recognized flag is matched above; anything left
393
403
  // starting with `--` is unrecognized, whether it arrived bare
@@ -524,11 +534,21 @@ Ops:
524
534
  ConvergeOp, read from the chant/lifecycle orphan
525
535
  branch alone — no daemon needs to be running
526
536
  (--env <env>, --json)
537
+ operator log Converge tick history and the gate resolutions
538
+ against it, merged into one timestamp-ordered
539
+ timeline, from the same orphan branch (--env <env>,
540
+ --op <name>, --since <iso>, --limit <n>, --json).
541
+ --json also carries the count of ledger lines that
542
+ were unreadable, so a short timeline is never
543
+ silently short
527
544
  approve <op> <gate> Record a gate's out-of-band resolution fact
528
- (--actor <name>, --note <text>) — the durable
529
- counterpart to a converge tick's gate-as-fact
530
- outcome; see the pending-gates list in operator
531
- status. Does not itself unblock the gated op's local
545
+ (--actor <name>, --note <text>, --url <url>) — the
546
+ durable counterpart to a converge tick's
547
+ gate-as-fact outcome; see the pending-gates list in
548
+ operator status. --url is the PR/MR the resolution
549
+ happened at, recorded typed rather than as free text,
550
+ and defaults to the PR/MR of the surrounding CI job.
551
+ Does not itself unblock the gated op's local
532
552
  dispatch (re-run --temporal, or merge its PR)
533
553
 
534
554
  graph Show Op dependency graph (--stacks for cross-stack order,
@@ -875,6 +895,7 @@ const registry: CommandDef[] = [
875
895
  { name: "run", handler: runOp },
876
896
 
877
897
  { name: "operator status", handler: runOperatorStatus },
898
+ { name: "operator log", handler: runOperatorLog },
878
899
  { name: "operator", handler: runOperator },
879
900
  { name: "approve", handler: runApprove },
880
901
 
@@ -285,8 +285,16 @@ export interface ParsedArgs {
285
285
  leaseTtl?: string;
286
286
  /** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
287
287
  once?: boolean;
288
- /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text context recorded on the gate-resolution fact (e.g. a PR URL). */
288
+ /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
289
289
  note?: string;
290
+ /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
291
+ op?: string;
292
+ /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
293
+ since?: string;
294
+ /** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
295
+ limit?: number;
296
+ /** `chant approve <op> <gate> --url <url>` (#2028) — the address this resolution happened at (the PR/MR that carried the change), recorded typed on the gate-resolution fact so a reader is not sniffing `--note` for something link-shaped. Defaults to the PR/MR the surrounding CI job is for, when there is one. Must be an absolute http/https URL. */
297
+ url?: string;
290
298
  }
291
299
 
292
300
  /**
@@ -7,9 +7,11 @@ import {
7
7
  appendConvergeRecord,
8
8
  readConvergeLedger,
9
9
  consecutiveRuleFires,
10
+ componentVerdicts,
10
11
  type ConvergeTickRecordInput,
11
12
  type ConvergeTickRecord,
12
13
  } from "./converge-ledger";
14
+ import type { ComponentStatusRow } from "./status";
13
15
 
14
16
  function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
15
17
  const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
@@ -171,6 +173,114 @@ describe("converge-ledger", () => {
171
173
  });
172
174
  });
173
175
 
176
+ // ── Per-component verdicts and tick id (#2027) ─────────────────────────────
177
+
178
+ describe("tick id", () => {
179
+ test("mints one per record, and two ticks in the same ISO second are still distinguishable", async () => {
180
+ await withTestDir(async (dir) => {
181
+ await initRepo(dir);
182
+ const a = await appendConvergeRecord(makeInput(), { cwd: dir });
183
+ const b = await appendConvergeRecord(makeInput(), { cwd: dir });
184
+
185
+ expect(a.record.id).toMatch(/^[0-9a-f-]{36}$/);
186
+ expect(a.record.timestamp).toBe(b.record.timestamp);
187
+ expect(a.record.id).not.toBe(b.record.id);
188
+
189
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
190
+ expect(records.map((r) => r.id)).toEqual([a.record.id, b.record.id]);
191
+ });
192
+ });
193
+
194
+ test("an explicitly supplied id wins over the mint", async () => {
195
+ await withTestDir(async (dir) => {
196
+ await initRepo(dir);
197
+ const { record } = await appendConvergeRecord(makeInput({ id: "tick-fixed" }), { cwd: dir });
198
+ expect(record.id).toBe("tick-fixed");
199
+ });
200
+ });
201
+
202
+ test("a pre-#2027 record with no id still reads, rather than being counted malformed", async () => {
203
+ await withTestDir(async (dir) => {
204
+ await initRepo(dir);
205
+ await appendConvergeRecord(makeInput(), { cwd: dir });
206
+ // Hand-write the pre-#2027 shape (no `id`) onto the same ledger,
207
+ // through the same git plumbing a real writer used before the field
208
+ // existed.
209
+ const { readBlobFromPath, writeBlobToPath } = await import("./git");
210
+ const legacy = JSON.stringify({ version: 1, ...makeInput({ timestamp: "2025-12-31T00:00:00.000Z" }) });
211
+ const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
212
+ await writeBlobToPath("staging", "converge.jsonl", `${existing}\n${legacy}`, "legacy", { cwd: dir });
213
+
214
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
215
+ expect(malformed).toBe(0);
216
+ expect(records).toHaveLength(2);
217
+ expect(records[1].id).toBeUndefined();
218
+ });
219
+ });
220
+ });
221
+
222
+ describe("componentVerdicts", () => {
223
+ const row = (over: Partial<ComponentStatusRow>): ComponentStatusRow => ({
224
+ component: "svc",
225
+ env: "staging",
226
+ reconciliation: "reconciled",
227
+ detail: "digest matches live",
228
+ ...over,
229
+ } as ComponentStatusRow);
230
+
231
+ test("keeps the verdict-bearing subset and drops the heavy release/build fields", () => {
232
+ const rows = [
233
+ row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true,
234
+ recorded: { version: 1 } as unknown as ComponentStatusRow["recorded"] }),
235
+ row({ component: "worker", reconciliation: "unknown", detail: "could not read live state",
236
+ unobserved: { reason: "no-credentials", detail: "no role assumed" } }),
237
+ ];
238
+ expect(componentVerdicts(rows)).toEqual([
239
+ { component: "api", reconciliation: "drifted", detail: "live digest differs", live: true },
240
+ {
241
+ component: "worker",
242
+ reconciliation: "unknown",
243
+ detail: "could not read live state",
244
+ unobserved: { reason: "no-credentials", detail: "no role assumed" },
245
+ },
246
+ ]);
247
+ });
248
+
249
+ test("caps a multi-line or oversized detail to one line, so the record stays one line of JSON", () => {
250
+ const verdicts = componentVerdicts([
251
+ row({ detail: "first line\nsecond line" }),
252
+ row({ component: "big", detail: "x".repeat(500) }),
253
+ ]);
254
+ expect(verdicts[0].detail).toBe("first line");
255
+ expect(verdicts[1].detail).toHaveLength(301); // 300 + the ellipsis
256
+ expect(JSON.stringify(verdicts)).not.toContain("\\n");
257
+ });
258
+
259
+ test("round-trips on a record, naming the component that tripped the tick's aggregate unknown", async () => {
260
+ await withTestDir(async (dir) => {
261
+ await initRepo(dir);
262
+ const { record } = await appendConvergeRecord(
263
+ makeInput({
264
+ components: componentVerdicts([
265
+ row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true }),
266
+ row({ component: "worker", reconciliation: "unknown", detail: "unreadable",
267
+ unobserved: { reason: "no-credentials" } }),
268
+ ]),
269
+ summary: { drifted: 1, remediated: 0, reported: 1, skippedBudget: 0, skippedFlap: 0, unobserved: 1, adopted: 0 },
270
+ }),
271
+ { cwd: dir },
272
+ );
273
+
274
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
275
+ expect(records).toEqual([record]);
276
+ // The count says "1 unobserved"; the verdicts say which one.
277
+ expect(records[0].summary.unobserved).toBe(1);
278
+ expect(records[0].components?.filter((c) => c.unobserved).map((c) => c.component)).toEqual(["worker"]);
279
+ expect(records[0].components?.find((c) => c.reconciliation === "drifted")?.component).toBe("api");
280
+ });
281
+ });
282
+ });
283
+
174
284
  // ── Concurrent local writers (#1485) ────────────────────────────────────────
175
285
 
176
286
  describe("appendConvergeRecord retries on RefCASConflictError", () => {
@@ -16,11 +16,29 @@
16
16
  * row include a given rule id, stopping at the first tick where it didn't
17
17
  * fire (the symptom cleared).
18
18
  */
19
+ import { randomUUID } from "node:crypto";
19
20
  import { sortedJsonReplacer } from "../utils";
21
+ import type { ComponentStatusRow } from "./status";
20
22
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
21
23
 
22
24
  const FILENAME = "converge.jsonl";
23
25
 
26
+ /**
27
+ * Cap a piece of free text to one sanitized line before it goes into a
28
+ * record. A record here is one line of JSON, so a multi-line or unbounded
29
+ * string folded into any field would break the line the ledger is built out
30
+ * of. Every free-text field a tick writes goes through this: a dispatch
31
+ * failure's `stderr` (lexicons/temporal's `sanitizeOneLine`, which delegates
32
+ * here) and a component verdict's lexicon-authored `detail`.
33
+ */
34
+ const MAX_LEDGER_TEXT_LEN = 300;
35
+ export function sanitizeLedgerText(raw: string, maxLen = MAX_LEDGER_TEXT_LEN): string {
36
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
37
+ // Strips literal control bytes; it is not matching on a range boundary.
38
+ const stripped = firstLine.replace(/[\x00-\x1f\x7f]/g, " ").trim();
39
+ return stripped.length > maxLen ? `${stripped.slice(0, maxLen)}…` : stripped;
40
+ }
41
+
24
42
  /**
25
43
  * Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
26
44
  * `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
@@ -50,14 +68,85 @@ export interface ConvergeRuleOutcome {
50
68
  op?: string;
51
69
  /** The gate's signal name, for `action: "gated"`. */
52
70
  gateName?: string;
71
+ /**
72
+ * Where this gate's approval happens (#2028), for `action: "gated"` — the
73
+ * PR carrying the change in a gate-as-PR flow, or whatever review surface
74
+ * the dispatching environment knows about.
75
+ *
76
+ * The pending fact is the one a human has to act on, and it used to carry
77
+ * no link at all: `chant operator status`'s pending row was
78
+ * `{rule, op, gate}` and the only affordance it could print was a shell
79
+ * command. Absent when there genuinely is no address — a local tick with no
80
+ * PR behind it — never a synthesized one. See
81
+ * `./gate-ledger.ts`'s `resolveApprovalUrl` for where it comes from, and
82
+ * `GateResolutionRecord.url` for the resolved counterpart.
83
+ */
84
+ url?: string;
53
85
  /** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
54
86
  reason?: string;
55
87
  }
56
88
 
89
+ /**
90
+ * One component's verdict as the tick observed it (#2027) — the per-entity
91
+ * layer behind the tick's single aggregate `status`.
92
+ *
93
+ * A deliberate subset of `ComponentStatusRow`: the five verdict-bearing
94
+ * fields, joined on the same `component` key `chant components status --live
95
+ * --json` emits, and none of the heavy ones. `recorded`, `build` and
96
+ * `componentBom` are dropped on purpose — they are release/build-ledger
97
+ * content, already readable by that same key, and a tick record is one line
98
+ * of JSON appended every tick forever. `detail` goes through
99
+ * {@link sanitizeLedgerText} for the same reason.
100
+ */
101
+ export interface ConvergeComponentVerdict {
102
+ component: string;
103
+ reconciliation: ComponentStatusRow["reconciliation"];
104
+ /** Human-readable detail backing the verdict, capped to one line. */
105
+ detail: string;
106
+ /** "Observed live", when live evidence was gathered. Absent means "did not look" or "could not look" — see `unobserved`. */
107
+ live?: boolean;
108
+ /** Why live state could not be read for this component (#1089). Mutually exclusive with `live`; this is the row that tripped an aggregate `status: "unknown"`. */
109
+ unobserved?: { reason: string; detail?: string };
110
+ }
111
+
112
+ /**
113
+ * Project the tick's `ComponentStatusRow[]` down to what the ledger keeps.
114
+ * Pure; row order is preserved, so a consumer sees components in the same
115
+ * order the status join produced them.
116
+ */
117
+ export function componentVerdicts(rows: readonly ComponentStatusRow[]): ConvergeComponentVerdict[] {
118
+ return rows.map((row) => ({
119
+ component: row.component,
120
+ reconciliation: row.reconciliation,
121
+ detail: sanitizeLedgerText(row.detail ?? ""),
122
+ ...(row.live !== undefined ? { live: row.live } : {}),
123
+ ...(row.unobserved
124
+ ? {
125
+ unobserved: {
126
+ reason: row.unobserved.reason,
127
+ ...(row.unobserved.detail !== undefined ? { detail: sanitizeLedgerText(row.unobserved.detail) } : {}),
128
+ },
129
+ }
130
+ : {}),
131
+ }));
132
+ }
133
+
57
134
  /** One immutable converge-tick record. */
58
135
  export interface ConvergeTickRecord {
59
136
  /** Schema version, so an incompatible future shape is detected before being misread. */
60
137
  version: 1;
138
+ /**
139
+ * Stable tick id (#2027) — the one thing an outcome, a gate fact or a
140
+ * remediation can point at. Before this, a tick's identity was the
141
+ * `(op, env, timestamp)` triple, so two ticks landing in the same ISO
142
+ * second were indistinguishable and nothing could reference one.
143
+ *
144
+ * Minted by {@link appendConvergeRecord} with `randomUUID()` (the same
145
+ * mint `./lease.ts` uses for a lease token) when the caller doesn't supply
146
+ * one. Optional on the type because `version: 1` records written before
147
+ * #2027 have none — a reader must handle its absence, not assume it.
148
+ */
149
+ id?: string;
61
150
  /** The ConvergeOp's name (`OpConfig.name`). */
62
151
  op: string;
63
152
  env: string;
@@ -67,6 +156,18 @@ export interface ConvergeTickRecord {
67
156
  firedRuleIds: string[];
68
157
  /** Per-rule outcome, for every fired rule. */
69
158
  outcomes: ConvergeRuleOutcome[];
159
+ /**
160
+ * The per-component verdicts this tick observed (#2027), behind the
161
+ * aggregate counts in `summary`.
162
+ *
163
+ * The tick derives these to compute `summary.drifted` and the whole-tick
164
+ * `status`, and used to throw them away — so a reader could say "this
165
+ * environment drifted twice this hour" but could not colour the node that
166
+ * drifted, or name the single unobserved component whose `unknown` verdict
167
+ * refused remediation for everything else. Optional because `version: 1`
168
+ * records written before #2027 have none; absent is not "no components".
169
+ */
170
+ components?: ConvergeComponentVerdict[];
70
171
  /** Aggregate counts backing the tick's one log line. */
71
172
  summary: {
72
173
  drifted: number;
@@ -90,6 +191,13 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
90
191
  * `pushLifecycle` (./git.ts) afterward, same two-step shape every other
91
192
  * ledger write here uses.
92
193
  *
194
+ * Mints `record.id` (#2027) when the input doesn't carry one, so every tick
195
+ * written from here on is referenceable. Unlike `timestamp`, which stays
196
+ * caller-supplied because library code never calls `Date.now()` internally,
197
+ * an id has nothing for a caller to decide — the mint lives here so no
198
+ * writer can forget it. An explicit `input.id` still wins, which is what a
199
+ * test asserting an exact record uses.
200
+ *
93
201
  * Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
94
202
  * — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
95
203
  * a *different* env's file on the same orphan branch (two operators ticking
@@ -108,8 +216,8 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
108
216
  export async function appendConvergeRecord(
109
217
  input: ConvergeTickRecordInput,
110
218
  opts?: { cwd?: string },
111
- ): Promise<{ commit: string; record: ConvergeTickRecord }> {
112
- const record: ConvergeTickRecord = { version: 1, ...input };
219
+ ): Promise<{ commit: string; record: ConvergeTickRecord & { id: string } }> {
220
+ const record: ConvergeTickRecord & { id: string } = { version: 1, ...input, id: input.id ?? randomUUID() };
113
221
  const json = JSON.stringify(record, sortedJsonReplacer);
114
222
 
115
223
  let lastErr: unknown;
@@ -3,7 +3,7 @@ import { withTestDir } from "@intentius/chant-test-utils";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
6
+ import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "./gate-ledger";
7
7
  import { readBlobFromPath } from "./git";
8
8
 
9
9
  function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
@@ -100,4 +100,84 @@ describe("lifecycle/gate-ledger", () => {
100
100
  expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
101
101
  });
102
102
  });
103
+ // ── Gate-as-fact carries an address (#2028) ──────────────────────────────
104
+
105
+ describe("resolveApprovalUrl", () => {
106
+ test("a GitHub Actions pull_request run resolves to that PR", () => {
107
+ expect(resolveApprovalUrl({
108
+ GITHUB_SERVER_URL: "https://github.com",
109
+ GITHUB_REPOSITORY: "INTENTIUS/chant",
110
+ GITHUB_REF_NAME: "2028/merge",
111
+ })).toBe("https://github.com/INTENTIUS/chant/pull/2028");
112
+ });
113
+
114
+ test("honours a GitHub Enterprise server url, trailing slash and all", () => {
115
+ expect(resolveApprovalUrl({
116
+ GITHUB_SERVER_URL: "https://ghe.example.com/",
117
+ GITHUB_REPOSITORY: "org/repo",
118
+ GITHUB_REF_NAME: "7/head",
119
+ })).toBe("https://ghe.example.com/org/repo/pull/7");
120
+ });
121
+
122
+ test("a GitLab merge-request pipeline resolves to that MR", () => {
123
+ expect(resolveApprovalUrl({
124
+ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo",
125
+ CI_MERGE_REQUEST_IID: "42",
126
+ })).toBe("https://gitlab.com/org/repo/-/merge_requests/42");
127
+ });
128
+
129
+ test("a push-event CI run, or no CI at all, has no address — undefined, never a guess", () => {
130
+ expect(resolveApprovalUrl({ GITHUB_REPOSITORY: "org/repo", GITHUB_REF_NAME: "main" })).toBeUndefined();
131
+ expect(resolveApprovalUrl({ GITHUB_REF_NAME: "3/merge" })).toBeUndefined();
132
+ expect(resolveApprovalUrl({ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo" })).toBeUndefined();
133
+ expect(resolveApprovalUrl({})).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe("isApprovalUrl", () => {
138
+ test("accepts absolute http/https", () => {
139
+ expect(isApprovalUrl("https://github.com/org/repo/pull/1")).toBe(true);
140
+ expect(isApprovalUrl("http://localhost:3000/pr/1")).toBe(true);
141
+ });
142
+
143
+ test("refuses anything a reader could not follow as a link", () => {
144
+ for (const bad of ["", "org/repo/pull/1", "/pull/1", "file:///etc/passwd", "javascript:alert(1)", "not a url"]) {
145
+ expect(isApprovalUrl(bad)).toBe(false);
146
+ }
147
+ });
148
+ });
149
+
150
+ test("a resolution round-trips its typed url alongside free-text note", async () => {
151
+ await withTestDir(async (dir) => {
152
+ await initRepo(dir);
153
+ const { record } = await appendGateResolution(
154
+ {
155
+ op: "fountain-apply",
156
+ gate: "rollout-gate",
157
+ resolvedBy: "alex",
158
+ timestamp: "2026-01-01T00:00:00.000Z",
159
+ note: "rolled staging first",
160
+ url: "https://github.com/INTENTIUS/chant/pull/2028",
161
+ },
162
+ { cwd: dir },
163
+ );
164
+ const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
165
+ expect(records).toEqual([record]);
166
+ expect(records[0].url).toBe("https://github.com/INTENTIUS/chant/pull/2028");
167
+ expect(records[0].note).toBe("rolled staging first");
168
+ });
169
+ });
170
+
171
+ test("a pre-#2028 resolution with no url still reads", async () => {
172
+ await withTestDir(async (dir) => {
173
+ await initRepo(dir);
174
+ await appendGateResolution(
175
+ { op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
176
+ { cwd: dir },
177
+ );
178
+ const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
179
+ expect(malformed).toBe(0);
180
+ expect(records[0].url).toBeUndefined();
181
+ });
182
+ });
103
183
  });
@@ -42,6 +42,58 @@ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASCo
42
42
  const DIR = "_gates";
43
43
  const APPEND_RETRY_ATTEMPTS = 5;
44
44
 
45
+ /**
46
+ * The address of the approval surface for a gate, resolved from the CI
47
+ * environment (#2028).
48
+ *
49
+ * #1485's argument for gate-as-fact was that approval gets an address:
50
+ * "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
51
+ * authorization model." What shipped recorded the gate and not the address,
52
+ * so a pending-approval card had nothing to link to and gate-as-PR stayed a
53
+ * convention. This is the narrow, honest half of that: when a tick (or a
54
+ * `chant approve`) runs inside the PR/MR job that carries the change, the
55
+ * loop genuinely knows where approval happens, and says so. Anywhere else it
56
+ * returns `undefined` and the field is simply absent — never a guess, never a
57
+ * synthesized link.
58
+ *
59
+ * The env-var fallback chain is the same one `--actor` and `--run-id` already
60
+ * use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
61
+ *
62
+ * - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
63
+ * `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
64
+ * `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
65
+ * - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
66
+ * `CI_MERGE_REQUEST_IID`.
67
+ */
68
+ export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
69
+ const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
70
+ if (prNumber && env.GITHUB_REPOSITORY) {
71
+ const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
72
+ return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
73
+ }
74
+
75
+ if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
76
+ return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Whether `raw` is an address worth recording as one: an absolute `http`/
84
+ * `https` URL. A gate's address is a link a reader is expected to follow, so
85
+ * a relative path or a `file:`/`javascript:` scheme is refused at the CLI
86
+ * boundary rather than written into an immutable record.
87
+ */
88
+ export function isApprovalUrl(raw: string): boolean {
89
+ try {
90
+ const parsed = new URL(raw);
91
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
45
97
  /** One immutable gate-resolution record. */
46
98
  export interface GateResolutionRecord {
47
99
  /** Schema version, so an incompatible future shape is detected before being misread. */
@@ -54,8 +106,19 @@ export interface GateResolutionRecord {
54
106
  resolvedBy: string;
55
107
  /** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
56
108
  timestamp: string;
57
- /** Optional free-text context (e.g. a PR URL — "or a merged PR" is the issue's other resolution path; recording its link here keeps both paths visible from one ledger). */
109
+ /** Optional free-text context. Before #2028 this was also where a PR link went by convention; put the link in {@link GateResolutionRecord.url} instead and leave this for prose. */
58
110
  note?: string;
111
+ /**
112
+ * The address this resolution happened at (#2028) — the PR that carried the
113
+ * change, the review thread, whatever the approval surface was. Typed, so
114
+ * "resolved by this PR" is machine-readable instead of a reader sniffing
115
+ * `note` for something that looks like a link.
116
+ *
117
+ * `chant approve --url` sets it; absent when the resolver genuinely had no
118
+ * address (a human at a terminal, no PR). Always an absolute `http`/`https`
119
+ * URL — see {@link isApprovalUrl}.
120
+ */
121
+ url?: string;
59
122
  }
60
123
 
61
124
  export type GateResolutionInput = Omit<GateResolutionRecord, "version">;