@intentius/chant 0.124.2 → 0.124.4

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.
Files changed (73) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/override-approve.d.ts +3 -1
  3. package/dist/cli/handlers/override-approve.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/lifecycle/gate-ledger.d.ts +1 -33
  8. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  9. package/dist/lifecycle/gate-pending.d.ts +41 -0
  10. package/dist/lifecycle/gate-pending.d.ts.map +1 -0
  11. package/dist/op/activities/activity-contracts.d.ts +5 -4
  12. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  13. package/dist/op/activities/decide.d.ts +2 -0
  14. package/dist/op/activities/decide.d.ts.map +1 -1
  15. package/dist/op/gate-point-run.d.ts +5 -77
  16. package/dist/op/gate-point-run.d.ts.map +1 -1
  17. package/dist/op/gate-point.d.ts +18 -0
  18. package/dist/op/gate-point.d.ts.map +1 -1
  19. package/dist/op/gate.d.ts +8 -0
  20. package/dist/op/gate.d.ts.map +1 -1
  21. package/dist/op/index.d.ts +4 -4
  22. package/dist/op/index.d.ts.map +1 -1
  23. package/dist/op/op-waves-notify.d.ts +2 -1
  24. package/dist/op/op-waves-notify.d.ts.map +1 -1
  25. package/dist/op/op-waves-run.d.ts +123 -10
  26. package/dist/op/op-waves-run.d.ts.map +1 -1
  27. package/dist/op/op-waves.d.ts +71 -6
  28. package/dist/op/op-waves.d.ts.map +1 -1
  29. package/dist/op/steward-points.d.ts +7 -0
  30. package/dist/op/steward-points.d.ts.map +1 -1
  31. package/dist/override-gate.d.ts +8 -2
  32. package/dist/override-gate.d.ts.map +1 -1
  33. package/dist/point-decision.d.ts +134 -0
  34. package/dist/point-decision.d.ts.map +1 -0
  35. package/dist/pr-forge.d.ts +37 -2
  36. package/dist/pr-forge.d.ts.map +1 -1
  37. package/dist/workspace/decide.d.ts +3 -1
  38. package/dist/workspace/decide.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/discovery-skip.e2e.test.ts +4 -1
  41. package/src/cli/handlers/operator.ts +25 -11
  42. package/src/cli/handlers/override-approve.test.ts +16 -0
  43. package/src/cli/handlers/override-approve.ts +6 -2
  44. package/src/cli/main.test.ts +3 -0
  45. package/src/cli/main.ts +9 -2
  46. package/src/cli/registry.ts +2 -0
  47. package/src/lifecycle/gate-ledger.ts +3 -53
  48. package/src/lifecycle/gate-pending.ts +62 -0
  49. package/src/notify-bundle.test.ts +20 -0
  50. package/src/op/activities/activity-contracts.ts +1 -0
  51. package/src/op/activities/decide.ts +3 -0
  52. package/src/op/gate-point-ci.e2e.test.ts +171 -0
  53. package/src/op/gate-point-mcp.e2e.test.ts +2 -0
  54. package/src/op/gate-point-run.ts +18 -149
  55. package/src/op/gate-point.e2e.test.ts +2 -0
  56. package/src/op/gate-point.test.ts +1 -1
  57. package/src/op/gate-point.ts +27 -3
  58. package/src/op/gate.ts +10 -0
  59. package/src/op/index.ts +12 -1
  60. package/src/op/op-waves-notify.ts +19 -2
  61. package/src/op/op-waves-run.ts +541 -76
  62. package/src/op/op-waves.test.ts +229 -0
  63. package/src/op/op-waves.ts +110 -10
  64. package/src/op/steward-points.ts +8 -0
  65. package/src/override-gate.test.ts +5 -0
  66. package/src/override-gate.ts +8 -2
  67. package/src/point-decision-bundle.test.ts +62 -0
  68. package/src/point-decision.e2e.test.ts +95 -0
  69. package/src/point-decision.ts +280 -0
  70. package/src/pr-forge.test.ts +62 -0
  71. package/src/pr-forge.ts +60 -9
  72. package/src/workspace/decide.ts +7 -2
  73. package/src/workspace/points-cli.ts +1 -1
@@ -0,0 +1,95 @@
1
+ /**
2
+ * #3816: a tool built into one file decides a gate that asks a decision point
3
+ * with `@intentius/chant/point-decision`, asking through the chant bin
4
+ * (`chantGatePointAsker`) and recording through its own gate ledger port.
5
+ * The question is open until a person answers it with `chant workspace points
6
+ * answer`; then the same call records the resolution that cites the answer,
7
+ * once per answer and plan. With `store: "ledger"` the question is held on
8
+ * `chant/lifecycle`, not in the working tree (#3815).
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
15
+ import { cleanScratch, workspace } from "./workspace/__fixtures__/decide-workspace";
16
+ import { defaultChantCommand } from "./workspace/conformance";
17
+ import { memoryGateLedgerPort } from "./op/gate";
18
+ import { chantGatePointAsker, evaluatePointGate, type PointGateCheck } from "./point-decision";
19
+
20
+ const PLAN = `jcs1-sha256:${"5".repeat(64)}`;
21
+
22
+ const POINTS = {
23
+ points: {
24
+ "prod-apply": {
25
+ title: "Apply this plan to prod",
26
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
27
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
28
+ deciders: [{ kind: "quorum", count: 1 }],
29
+ },
30
+ },
31
+ };
32
+
33
+ const command = defaultChantCommand();
34
+ let root: string;
35
+
36
+ const chant = (...args: string[]): Record<string, unknown> => {
37
+ let out: string;
38
+ try {
39
+ out = execFileSync(command[0], [...command.slice(1), ...args], { cwd: root, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
40
+ } catch (e) {
41
+ out = String((e as { stdout?: string }).stdout ?? "");
42
+ }
43
+ return JSON.parse(out) as Record<string, unknown>;
44
+ };
45
+
46
+ const answersInTree = (): string[] => (existsSync(join(root, "answers")) ? readdirSync(join(root, "answers")).filter((f) => f.endsWith(".md")) : []);
47
+
48
+ beforeAll(() => {
49
+ root = workspace(POINTS);
50
+ vi.stubEnv("CI", "");
51
+ });
52
+
53
+ afterAll(() => {
54
+ vi.unstubAllEnvs();
55
+ cleanScratch();
56
+ });
57
+
58
+ describe("a gate-point decision through the chant bin (#3816)", () => {
59
+ test("waits on the open question, then passes on a person's answer, once per answer and plan", async () => {
60
+ const port = memoryGateLedgerPort();
61
+ const asker = chantGatePointAsker({ command, cwd: root });
62
+ const input = { op: "tf-apply", gate: "approve-apply", point: { name: "prod-apply" }, planDigest: PLAN, now: "2026-10-11T13:00:00.000Z" };
63
+
64
+ const first = await evaluatePointGate(port, asker, input);
65
+ expect(first).toMatchObject({ satisfied: false, recorded: true, waiting: { point: "prod-apply", state: "escalated", subject: "gate:tf-apply/approve-apply" } });
66
+ const waiting = first as Extract<PointGateCheck, { waiting: unknown }>;
67
+ expect(port.appended).toEqual([expect.objectContaining({ op: "tf-apply", gate: "approve-apply", planDigest: PLAN, answer: { point: "prod-apply", id: waiting.waiting.id, path: waiting.waiting.path } })]);
68
+
69
+ // Asked again while open, the standing pending fact is reused.
70
+ expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: false, recorded: false });
71
+
72
+ expect(chant("workspace", "points", "answer", waiting.waiting.id, "--answer", "yes", "--by", "alice")).toMatchObject({ written: true, question: { state: "answered" } });
73
+
74
+ const passed = await evaluatePointGate(port, asker, input);
75
+ expect(passed).toMatchObject({ satisfied: true, recorded: true, resolution: { resolvedBy: "alice", planDigest: PLAN, answer: { id: waiting.waiting.id, answer: true, decider: "quorum", answeredBy: ["alice"] } } });
76
+ expect(await evaluatePointGate(port, asker, input)).toMatchObject({ satisfied: true, recorded: false });
77
+ expect(port.resolved).toHaveLength(1);
78
+ }, 120_000);
79
+
80
+ test("a point with store ledger holds the question on chant/lifecycle, and an answer it does not pass on refuses", async () => {
81
+ const port = memoryGateLedgerPort();
82
+ const asker = chantGatePointAsker({ command, cwd: root });
83
+ const before = answersInTree();
84
+ const input = { op: "ledger-apply", gate: "approve-apply", point: { name: "prod-apply", store: "ledger" as const }, planDigest: PLAN };
85
+
86
+ const first = (await evaluatePointGate(port, asker, input)) as Extract<PointGateCheck, { waiting: unknown }>;
87
+ expect(first.waiting.state).toBe("escalated");
88
+ expect(answersInTree()).toEqual(before);
89
+
90
+ chant("workspace", "points", "answer", first.waiting.id, "--answer", "no", "--by", "alice");
91
+ const refused = await evaluatePointGate(port, asker, input);
92
+ expect(refused).toMatchObject({ satisfied: false, answer: { id: first.waiting.id, answer: false } });
93
+ expect("refused" in refused && refused.refused).toContain("passes only on true");
94
+ }, 120_000);
95
+ });
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The decision for a gate that asks a decision point (#3170), for a tool that
3
+ * runs from one bundled file (#3816).
4
+ *
5
+ * `@intentius/chant/point-decision` holds chant's rule for such a gate: ask
6
+ * the point with the gate's `gate.*` facts ({@link gatePointInputs}, under
7
+ * {@link gateSubject}), then
8
+ *
9
+ * - the question is open: record a pending fact that cites it, once per
10
+ * question and plan, and wait;
11
+ * - it is answered with an answer the gate passes on (`pass`, default yes):
12
+ * record a resolution that cites the answer, once per answer and plan, and
13
+ * go through;
14
+ * - it is answered otherwise: refuse, naming the record.
15
+ *
16
+ * Both halves are injected. A {@link GatePointAsker} asks the point (chant's
17
+ * own, `workspaceGatePointAsker` in `@intentius/chant/op`, reads the
18
+ * workspace; {@link chantGatePointAsker} runs the chant bin instead), and a
19
+ * {@link PointGateLedgerPort} reads and appends the gate ledger. So this module imports no workspace, CLI,
20
+ * TypeScript, zod or git code, which `point-decision-bundle.test.ts` holds
21
+ * it to. `./op/gate-point-run.ts` re-exports it, and chant's own runs decide
22
+ * by it.
23
+ */
24
+
25
+ import { execFile } from "node:child_process";
26
+ import { isPendingGateExpired, latestPendingGate } from "./lifecycle/gate-ledger-select";
27
+ import { DEFAULT_GATE_EXPIRY, resolveApprovalUrl } from "./lifecycle/gate-pending";
28
+ import { samePlanDigest } from "./lifecycle/plan-digest";
29
+ import type { GateAnswerRef, GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
30
+ import { parseDuration } from "./op/duration";
31
+ import type { GateLedgerPort } from "./op/gate";
32
+ import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
33
+ import type { WaitingPoint } from "./op/steward-points";
34
+
35
+ export {
36
+ DEFAULT_GATE_POINT_PASS,
37
+ GATE_POINT_INPUTS,
38
+ defaultGatePointStore,
39
+ gatePointInputs,
40
+ gatePointOf,
41
+ gatePointProblems,
42
+ gateSubject,
43
+ type GatePoint,
44
+ type GatePointFacts,
45
+ type GatePointStore,
46
+ } from "./op/gate-point";
47
+ export type { GateAnswerRef, GateResolutionRecord, GateResolutionInput, PendingGateInput, PendingGateRecord } from "./lifecycle/gate-ledger";
48
+ export type { WaitingPoint } from "./op/steward-points";
49
+
50
+ /**
51
+ * The gate ledger as {@link evaluatePointGate} uses it: read an op's facts,
52
+ * append a pending fact, append a resolution. chant's own is
53
+ * `gitGateLedgerPort` (`@intentius/chant/op`), on `chant/lifecycle`.
54
+ */
55
+ export type PointGateLedgerPort = Pick<GateLedgerPort, "read" | "appendPending" | "appendResolution">;
56
+
57
+ /** The question a gate's point was asked, as the gate reads it. */
58
+ export interface GatePointQuestion {
59
+ /** The answer record's id: what `points answer` takes. */
60
+ id: string;
61
+ point: string;
62
+ /** The record, from the repository root. */
63
+ path: string;
64
+ state: "escalated" | "proposed" | "answered";
65
+ /** The answer, or null while the question is open. */
66
+ answer: string | boolean | null;
67
+ /** The decider that gave the recorded state: table, model or quorum. */
68
+ decider: string;
69
+ answeredBy: string[];
70
+ subject: string | null;
71
+ /** The steward whose turn asked it, or null. */
72
+ steward: string | null;
73
+ }
74
+
75
+ /** What a gate asks its point with. */
76
+ export interface GatePointRequest {
77
+ point: string;
78
+ /** The authored inputs, references resolved. */
79
+ inputs: Record<string, unknown>;
80
+ facts: GatePointFacts;
81
+ /** What the answer record's `constrains` names: {@link gateSubject}. */
82
+ subject: string;
83
+ /** Where the question goes, as the gate's `point.store` declares it (#3815). Undefined leaves it to the asker. */
84
+ store?: GatePointStore;
85
+ }
86
+
87
+ /**
88
+ * How a gate asks its point. A test passes a stub; `workspaceGatePointAsker`
89
+ * (`@intentius/chant/op`) is chant's, which asks through the workspace. A tool
90
+ * built into one file passes its own, such as one that runs the chant bin.
91
+ */
92
+ export interface GatePointAsker {
93
+ ask(request: GatePointRequest): Promise<GatePointQuestion>;
94
+ }
95
+
96
+ /** What the run needs to decide a gate that asks a point. */
97
+ export interface PointGateInput {
98
+ op: string;
99
+ gate: string;
100
+ point: GatePoint;
101
+ /** The authored inputs, references resolved. */
102
+ inputs?: Record<string, unknown>;
103
+ description?: string;
104
+ timeout?: string;
105
+ runId?: string;
106
+ planDigest?: string;
107
+ /** The environment the run was started for: the `gate.env` input. Op gate facts on the ledger record none. */
108
+ env?: string;
109
+ now?: string;
110
+ }
111
+
112
+ /** What reaching a gate that asks a point decided. */
113
+ export type PointGateCheck =
114
+ | {
115
+ satisfied: true;
116
+ /** The resolution that cites the answer: this run's, or one an earlier run wrote for the same answer and plan. */
117
+ resolution: GateResolutionRecord;
118
+ answer: GateAnswerRef;
119
+ /** Whether this run appended the resolution. */
120
+ recorded: boolean;
121
+ pushed?: boolean;
122
+ pushWarning?: string;
123
+ }
124
+ | {
125
+ satisfied: false;
126
+ /** The question is answered, with an answer the gate does not pass on. */
127
+ refused: string;
128
+ answer: GateAnswerRef;
129
+ }
130
+ | {
131
+ satisfied: false;
132
+ /** The open question the run waits on. */
133
+ waiting: WaitingPoint;
134
+ /** The pending fact that cites it. */
135
+ pending: PendingGateRecord;
136
+ recorded: boolean;
137
+ pushed?: boolean;
138
+ pushWarning?: string;
139
+ };
140
+
141
+ /** Who a resolution written for an answer is by: the people who answered, or the decider that did. */
142
+ function resolvedByOf(q: GatePointQuestion): string {
143
+ return q.answeredBy.length > 0 ? q.answeredBy.join(", ") : q.decider;
144
+ }
145
+
146
+ /** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
147
+ export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
148
+ const now = input.now ?? new Date().toISOString();
149
+ const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
150
+ const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env), ...(input.point.store ? { store: input.point.store } : {}) });
151
+ const ref: GateAnswerRef = { point: q.point, id: q.id, path: q.path };
152
+
153
+ if (q.state === "answered" && q.answer !== null) {
154
+ const answer: GateAnswerRef = { ...ref, answer: q.answer, decider: q.decider, ...(q.answeredBy.length > 0 ? { answeredBy: q.answeredBy } : {}) };
155
+ const pass = input.point.pass ?? DEFAULT_GATE_POINT_PASS;
156
+ if (!pass.includes(q.answer)) {
157
+ return {
158
+ satisfied: false,
159
+ answer,
160
+ refused:
161
+ `decision point ${q.point} answered ${JSON.stringify(q.answer)} (${q.id}, by ${resolvedByOf(q)}), and gate "${input.gate}" passes only on ${pass.map((a) => JSON.stringify(a)).join(" or ")}. ` +
162
+ `To ask again, take the answer back: chant workspace points retract ${q.id} --by <name>`,
163
+ };
164
+ }
165
+ const ledger = await port.read(input.op);
166
+ const standing = ledger.resolutions.find(
167
+ (r) => r.gate === input.gate && r.answer?.id === q.id && r.environment === undefined && samePlanDigest(r.planDigest, input.planDigest),
168
+ );
169
+ if (standing) return { satisfied: true, resolution: standing, answer, recorded: false };
170
+ if (!port.appendResolution) throw new Error(`gate "${input.gate}" asks decision point ${q.point}, and this gate ledger cannot record the resolution that cites its answer`);
171
+ const { record, pushed, pushWarning } = await port.appendResolution({
172
+ op: input.op,
173
+ gate: input.gate,
174
+ resolvedBy: resolvedByOf(q),
175
+ timestamp: now,
176
+ note: `answered through decision point ${q.point} (${q.id})`,
177
+ ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
178
+ approver: { kind: q.decider === "quorum" || q.answeredBy.length > 0 ? "human" : "agent" },
179
+ answer,
180
+ });
181
+ return { satisfied: true, resolution: record, answer, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
182
+ }
183
+
184
+ const waiting: WaitingPoint = { id: q.id, point: q.point, state: q.state === "proposed" ? "proposed" : "escalated", path: q.path, subject: q.subject, steward: q.steward };
185
+ const ledger = await port.read(input.op);
186
+ const standing = latestPendingGate(ledger.pending.filter((p) => p.environment === undefined), input.gate);
187
+ if (standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) && standing.answer?.id === q.id) {
188
+ return { satisfied: false, waiting, pending: standing, recorded: false };
189
+ }
190
+ const url = resolveApprovalUrl();
191
+ const { record, pushed, pushWarning } = await port.appendPending({
192
+ op: input.op,
193
+ gate: input.gate,
194
+ timestamp: now,
195
+ expiresAt: new Date(new Date(now).getTime() + parseDuration(input.timeout ?? DEFAULT_GATE_EXPIRY)).toISOString(),
196
+ ...(input.description ? { description: input.description } : {}),
197
+ ...(input.runId ? { runId: input.runId } : {}),
198
+ ...(url ? { url } : {}),
199
+ ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
200
+ answer: ref,
201
+ });
202
+ return { satisfied: false, waiting, pending: record, recorded: true, pushed, ...(pushWarning ? { pushWarning } : {}) };
203
+ }
204
+
205
+ /** How {@link chantGatePointAsker} runs chant. */
206
+ export interface ChantGatePointAskerOptions {
207
+ /** The chant command and its leading arguments, such as `["npx", "chant"]` or `[process.execPath, "/path/to/chant"]`. */
208
+ command: string[];
209
+ /** Where the workspace is found. Defaults to the working directory. */
210
+ cwd?: string;
211
+ }
212
+
213
+ /** Run chant with `args` and parse the JSON document it prints. A refusal is a document too, and exits 1. */
214
+ function runChantJson(opts: ChantGatePointAskerOptions, args: string[]): Promise<Record<string, unknown>> {
215
+ const [bin, ...lead] = opts.command;
216
+ return new Promise((resolve, reject) => {
217
+ execFile(bin, [...lead, ...args], { cwd: opts.cwd, encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NO_COLOR: "1" } }, (err, stdout, stderr) => {
218
+ try {
219
+ resolve(JSON.parse(stdout) as Record<string, unknown>);
220
+ } catch {
221
+ reject(new Error(`chant ${args.slice(0, 3).join(" ")} printed no JSON document${err ? `: ${err.message}` : ""}${stderr ? `\n${stderr.trim()}` : ""}`));
222
+ }
223
+ });
224
+ });
225
+ }
226
+
227
+ function refusal(what: string, doc: Record<string, unknown>): Error | undefined {
228
+ const e = doc.error as { code?: string; message?: string } | undefined;
229
+ return e ? new Error(`${what}: ${e.code}: ${e.message}`) : undefined;
230
+ }
231
+
232
+ /**
233
+ * A {@link GatePointAsker} that runs the chant bin, for a tool that bundles
234
+ * this module and not the workspace code (#3816). It reads the point's
235
+ * declared inputs with `chant workspace points --json`, passes the gate's
236
+ * `gate.*` ones ({@link gatePointInputs}), and asks with `chant workspace
237
+ * points ask <point> --inputs <json> --subject <gate> --json`, with `--store`
238
+ * from the gate's `point.store` or, naming none, the ledger in a CI job
239
+ * ({@link defaultGatePointStore}). chant calls no model here, so a point's
240
+ * model decider is not asked and its question goes to people; a table and a
241
+ * quorum decide as in chant's own runs.
242
+ */
243
+ export function chantGatePointAsker(opts: ChantGatePointAskerOptions): GatePointAsker {
244
+ return {
245
+ async ask(request) {
246
+ const listed = await runChantJson(opts, ["workspace", "points", "--json"]);
247
+ const bad = refusal(`decision point ${request.point}`, listed);
248
+ if (bad) throw bad;
249
+ const point = (listed.points as { name: string; inputs: { name: string }[] }[] | undefined)?.find((p) => p.name === request.point);
250
+ if (!point) throw new Error(`gate "${request.facts.name}" asks decision point ${request.point}, and no points file declares it`);
251
+ const inputs = gatePointInputs(request.point, point.inputs.map((i) => i.name), request.facts, request.inputs);
252
+ const store = request.store ?? defaultGatePointStore();
253
+ const doc = await runChantJson(opts, ["workspace", "points", "ask", request.point, "--inputs", JSON.stringify(inputs), "--subject", request.subject, ...(store ? ["--store", store] : [])]);
254
+ const failed = refusal(`decision point ${request.point}`, doc);
255
+ if (failed) throw failed;
256
+ const q = doc.question as {
257
+ id: string;
258
+ point: string;
259
+ path: string;
260
+ state: GatePointQuestion["state"];
261
+ answer: string | boolean | null;
262
+ decider: { kind?: unknown };
263
+ answeredBy: string[];
264
+ subject: string | null;
265
+ askedBy: { steward: string } | null;
266
+ };
267
+ return {
268
+ id: q.id,
269
+ point: q.point,
270
+ path: q.path,
271
+ state: q.state,
272
+ answer: q.state === "answered" ? q.answer : null,
273
+ decider: typeof q.decider?.kind === "string" ? q.decider.kind : "quorum",
274
+ answeredBy: q.answeredBy ?? [],
275
+ subject: q.subject,
276
+ steward: q.askedBy?.steward ?? null,
277
+ };
278
+ },
279
+ };
280
+ }
@@ -613,3 +613,65 @@ describe("forgeTargetFromEnv (#3759)", () => {
613
613
  expect(() => forgeTargetFromEnv({})).toThrow(/forge is not known/);
614
614
  });
615
615
  });
616
+
617
+ describe("addComment and findIssue (#3818)", () => {
618
+ const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
619
+
620
+ test("addComment posts a new comment every time, with no read and no marker", async () => {
621
+ const { fetch, calls } = fakeFetch({ "POST /repos/acme/infra/issues/12/comments": { id: 9, html_url: "https://h/9" } });
622
+ const forge = githubForge({ ...gh, fetch });
623
+ expect(await forge.addComment(12, "terragucci: locked")).toEqual({ id: 9, url: "https://h/9" });
624
+ await forge.addComment(12, "terragucci: locked");
625
+ expect(calls.map((c) => c.method)).toEqual(["POST", "POST"]);
626
+ expect(calls[0].body).toEqual({ body: "terragucci: locked" });
627
+ });
628
+
629
+ test("addComment on Forgejo and GitLab", async () => {
630
+ const fj = fakeFetch({ "POST /api/v1/repos/acme/infra/issues/4/comments": { id: 2, html_url: "https://cb/2" } });
631
+ expect(await forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch: fj.fetch }).addComment(4, "hi")).toEqual({ id: 2, url: "https://cb/2" });
632
+ const gl = fakeFetch({ "POST /api/v4/projects/42/merge_requests/5/notes": { id: 77, body: "hi" } });
633
+ const r = await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).addComment(5, "hi");
634
+ expect(r).toEqual({ id: 77 });
635
+ expect(gl.calls).toHaveLength(1);
636
+ expect(gl.calls[0].body).toEqual({ body: "hi" });
637
+ });
638
+
639
+ test("trackIssue says when the issue was opened", async () => {
640
+ const open = { number: 8, title: "Drift", body: "<!-- d -->\nold", html_url: "https://h/issues/8", created_at: "2026-10-01T00:00:00Z" };
641
+ const upd = fakeFetch({ "GET /repos/acme/infra/issues": [open], "PATCH /repos/acme/infra/issues/8": { ...open, body: "<!-- d -->\nnew" } });
642
+ expect(await githubForge({ ...gh, fetch: upd.fetch }).trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nnew" })).toEqual({
643
+ action: "updated", number: 8, url: "https://h/issues/8", created: "2026-10-01T00:00:00Z",
644
+ });
645
+ const made = fakeFetch({ "GET /repos/acme/infra/issues": [], "POST /repos/acme/infra/issues": { ...open, number: 9, html_url: "https://h/issues/9", created_at: "2026-10-11T00:00:00Z" } });
646
+ expect(await githubForge({ ...gh, fetch: made.fetch }).trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nb" })).toMatchObject({ action: "opened", number: 9, created: "2026-10-11T00:00:00Z" });
647
+ const gl = fakeFetch({ "GET /api/v4/projects/42/issues": [{ iid: 3, title: "Drift", description: "<!-- d -->\nold", web_url: "https://gl/i/3", created_at: "2026-09-30T00:00:00Z" }] });
648
+ const r = await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).trackIssue("<!-- d -->", null);
649
+ expect(r).toEqual({ action: "left", number: 3, url: "https://gl/i/3", created: "2026-09-30T00:00:00Z" });
650
+ });
651
+
652
+ test("findIssue reads the oldest open match with no write and no token", async () => {
653
+ const { fetch, calls } = fakeFetch({
654
+ "GET /repos/acme/infra/issues": [
655
+ { number: 5, title: "PR", body: "<!-- d -->", pull_request: {} },
656
+ { number: 7, title: "newer", body: "<!-- d -->\nb", user: { login: "bot" }, created_at: "2026-10-05T00:00:00Z", html_url: "https://h/issues/7" },
657
+ { number: 6, title: "planted", body: "<!-- d -->\nx", user: { login: "mallory" }, created_at: "2026-10-04T00:00:00Z" },
658
+ ],
659
+ });
660
+ const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", fetch });
661
+ expect(await forge.findIssue("<!-- d -->", { author: "bot" })).toEqual({ number: 7, title: "newer", body: "<!-- d -->\nb", created: "2026-10-05T00:00:00Z", url: "https://h/issues/7" });
662
+ expect((await forge.findIssue("<!-- d -->"))?.number).toBe(6);
663
+ expect(await forge.findIssue("<!-- none -->")).toBeUndefined();
664
+ expect(calls.every((c) => c.method === "GET" && !("Authorization" in c.headers))).toBe(true);
665
+ });
666
+
667
+ test("findIssue on GitLab and Forgejo, tokenless", async () => {
668
+ const gl = fakeFetch({ "GET /api/v4/projects/42/issues": [{ iid: 3, title: "Drift", description: "<!-- d -->\nb", web_url: "https://gl/i/3", created_at: "2026-09-30T00:00:00Z" }] });
669
+ const g = gitlabForge({ apiBase: "https://gl/api/v4", project: "42", fetch: gl.fetch });
670
+ expect(await g.findIssue("<!-- d -->")).toEqual({ number: 3, title: "Drift", body: "<!-- d -->\nb", created: "2026-09-30T00:00:00Z", url: "https://gl/i/3" });
671
+ expect(Object.keys(gl.calls[0].headers)).toEqual([]);
672
+ const fj = fakeFetch({ "GET /api/v1/repos/acme/infra/issues": [{ number: 2, title: "t", body: "<!-- d -->", created_at: "2026-10-02T00:00:00Z" }] });
673
+ const f = forgejoForge({ apiBase: "https://codeberg.org/api/v1", repo: "acme/infra", host: "codeberg.org", fetch: fj.fetch });
674
+ expect((await f.findIssue("<!-- d -->"))?.created).toBe("2026-10-02T00:00:00Z");
675
+ expect(fj.calls[0].headers.Authorization).toBeUndefined();
676
+ });
677
+ });
package/src/pr-forge.ts CHANGED
@@ -85,6 +85,25 @@ export interface FoundComment {
85
85
  url?: string;
86
86
  }
87
87
 
88
+ /** What {@link PrForge.addComment} posted. */
89
+ export interface AddedComment {
90
+ /** The new comment's or note's id. */
91
+ id: number;
92
+ /** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
93
+ url?: string;
94
+ }
95
+
96
+ /** An open tracking issue {@link PrForge.findIssue} read back. */
97
+ export interface FoundIssue {
98
+ /** The issue's number (GitHub, Forgejo) or iid (GitLab). */
99
+ number: number;
100
+ title: string;
101
+ body: string;
102
+ /** When the issue was opened: the forge's `created_at`, an ISO 8601 string. */
103
+ created?: string;
104
+ url?: string;
105
+ }
106
+
88
107
  /** A finding to keep in a tracking issue. `body` carries the marker. */
89
108
  export interface IssueFinding {
90
109
  title: string;
@@ -123,6 +142,8 @@ export interface IssueResult {
123
142
  /** The issue's number (GitHub, Forgejo) or iid (GitLab). */
124
143
  number?: number;
125
144
  url?: string;
145
+ /** When the issue was opened (the forge's `created_at`, ISO 8601), for an issue that exists after the call or was just closed. */
146
+ created?: string;
126
147
  }
127
148
 
128
149
  export interface PrForge {
@@ -155,6 +176,13 @@ export interface PrForge {
155
176
  * GitLab's system notes are skipped.
156
177
  */
157
178
  findComment(pr: number, marker: string, opts?: MarkerMatch): Promise<FoundComment | undefined>;
179
+ /**
180
+ * Post a new comment on pull request `pr` (a new note on merge request
181
+ * `pr`). No marker is looked for or required, and an earlier comment is
182
+ * never edited: every call adds one. For replies, where
183
+ * {@link upsertComment} would overwrite the last.
184
+ */
185
+ addComment(pr: number, body: string): Promise<AddedComment>;
158
186
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
159
187
  /**
160
188
  * Keep one OPEN tracking issue for `marker` in line with `finding`: open
@@ -164,6 +192,13 @@ export interface PrForge {
164
192
  * taken for issues. When several match, the oldest is kept.
165
193
  */
166
194
  trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
195
+ /**
196
+ * The OPEN issue {@link trackIssue} would keep for `marker`: the oldest one
197
+ * {@link MarkerMatch} finds, `author` honoured, pull requests left out.
198
+ * Undefined when there is none. Writes nothing, so a client built without a
199
+ * token reads a public repository's issue with it.
200
+ */
201
+ findIssue(marker: string, opts?: MarkerMatch): Promise<FoundIssue | undefined>;
167
202
  /**
168
203
  * {@link trackIssue} by prefix for a finding, without the body check,
169
204
  * resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
@@ -247,6 +282,8 @@ interface Thread {
247
282
  title?: string;
248
283
  author?: string;
249
284
  url?: string;
285
+ /** An issue's `created_at`. */
286
+ created?: string;
250
287
  }
251
288
 
252
289
  /** The calls each forge makes for the shared comment and issue recipes. */
@@ -298,7 +335,8 @@ async function upsertCommentWith(t: Threads, pr: number, marker: string, body: s
298
335
  async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
299
336
  if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
300
337
  const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
301
- const at = (i: Thread | undefined): Omit<IssueResult, "action"> => (i ? { number: i.id, ...(i.url ? { url: i.url } : {}) } : {});
338
+ const at = (i: Thread | undefined): Omit<IssueResult, "action"> =>
339
+ i ? { number: i.id, ...(i.url ? { url: i.url } : {}), ...(i.created ? { created: i.created } : {}) } : {};
302
340
  if (finding === null) {
303
341
  if (!mine) return { action: "none" };
304
342
  if (!opts.closeOnClear) return { action: "left", ...at(mine) };
@@ -318,8 +356,17 @@ function sameIssue(open: IssueFinding, finding: IssueFinding, sameWhen: TrackIss
318
356
  }
319
357
 
320
358
  /** The recipes every client shares, over its own {@link Threads}. */
321
- function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "trackIssue" | "upsertIssue"> {
359
+ function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "addComment" | "trackIssue" | "findIssue" | "upsertIssue"> {
322
360
  return {
361
+ async addComment(pr, body) {
362
+ const made = await t.addComment(pr, body);
363
+ return { id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
364
+ },
365
+ async findIssue(marker, opts = {}) {
366
+ if (!marker) throw new Error("the issue needs a marker to be found");
367
+ const i = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
368
+ return i ? { number: i.id, title: i.title ?? "", body: i.body, ...(i.created ? { created: i.created } : {}), ...(i.url ? { url: i.url } : {}) } : undefined;
369
+ },
323
370
  async findComment(pr, marker, opts = {}) {
324
371
  const c = await ownComment(t, pr, marker, opts);
325
372
  return c ? { id: c.id, body: c.body, ...(c.url ? { url: c.url } : {}) } : undefined;
@@ -336,7 +383,8 @@ export interface GithubForgeOptions {
336
383
  apiBase: string;
337
384
  /** `owner/name`. */
338
385
  repo: string;
339
- token: string;
386
+ /** Without one, no credential is sent: enough to read a public repository ({@link PrForge.findComment}, {@link PrForge.findIssue}). */
387
+ token?: string;
340
388
  /** The forge's host, for naming reviewers. Default github.com. */
341
389
  host?: string;
342
390
  runUrl?: string;
@@ -354,8 +402,8 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
354
402
  const api = transport(
355
403
  opts.apiBase,
356
404
  kind === "github"
357
- ? { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }
358
- : { Authorization: `token ${opts.token}`, Accept: "application/json" },
405
+ ? { ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), Accept: "application/vnd.github+json" }
406
+ : { ...(opts.token ? { Authorization: `token ${opts.token}` } : {}), Accept: "application/json" },
359
407
  opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch),
360
408
  );
361
409
  const repo = `/repos/${opts.repo}`;
@@ -368,7 +416,8 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
368
416
  if (id === undefined) return undefined;
369
417
  const author = loginOf(v);
370
418
  const url = str(v.html_url);
371
- return { id, body: str(v.body) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
419
+ const created = str(v.created_at);
420
+ return { id, body: str(v.body) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
372
421
  };
373
422
  const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
374
423
  const t: Threads = {
@@ -452,7 +501,8 @@ export interface GitlabForgeOptions {
452
501
  apiBase: string;
453
502
  /** `$CI_PROJECT_ID`, or the URL-encoded path. */
454
503
  project: string;
455
- token: string;
504
+ /** Without one, no credential is sent: enough to read a public project ({@link PrForge.findComment}, {@link PrForge.findIssue}). */
505
+ token?: string;
456
506
  /**
457
507
  * The header the token goes in: `PRIVATE-TOKEN` (the default) for an
458
508
  * access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
@@ -474,7 +524,7 @@ const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending",
474
524
  */
475
525
  export function gitlabForge(opts: GitlabForgeOptions): PrForge {
476
526
  const host = (opts.host ?? "gitlab.com").toLowerCase();
477
- const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
527
+ const api = transport(opts.apiBase, opts.token ? { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token } : {}, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
478
528
  const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
479
529
  const pageParams = (page: number) => `per_page=100&page=${page}`;
480
530
  const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
@@ -491,7 +541,8 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
491
541
  if (id === undefined) return undefined;
492
542
  const author = authorOf(v);
493
543
  const url = str(v.web_url);
494
- return { id, body: str(v.description) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
544
+ const created = str(v.created_at);
545
+ return { id, body: str(v.description) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
495
546
  };
496
547
  const notes = `${project}/merge_requests`;
497
548
  const t: Threads = {
@@ -445,7 +445,9 @@ export interface AskPointOptions {
445
445
  * or the lifecycle ledger (#2786). Defaults to the ledger in a steward's
446
446
  * turn (`steward` given, or this process a steward's turn), since a steward
447
447
  * never writes the checkout, and to the tree otherwise. A question already
448
- * held on the ledger is rewritten there whatever this says.
448
+ * held on the ledger is rewritten there whatever this says. Outside a
449
+ * steward's turn, `points ask --store ledger` and a gate's `point.store`
450
+ * reach it (#3815), for a checkout that does not outlive the run.
449
451
  */
450
452
  store?: "tree" | "ledger";
451
453
  /** The date written as asked_on, and answered_on for a table's answer, YYYY-MM-DD. Defaults to today, in UTC. */
@@ -823,7 +825,10 @@ async function findQuestion(cwd: string, kind: string | undefined, id: string):
823
825
  if (kinds.length === 0) throw new PointsWriteError("points-undeclared", "no record kind with an answers block is declared: name one with --kind, or declare one in chant.workspace.json");
824
826
  for (const k of kinds) {
825
827
  const opened = await openAnswers(k, cwd);
826
- // A question a steward asked is on the ledger (#2786), and people's answer goes back there.
828
+ // A question a steward asked is on the ledger (#2786), and people's answer goes back there. So is
829
+ // one a CI job asked with --store ledger (#3815), from a checkout that is gone: fetch the
830
+ // branch first, so a clone that never ran the job finds the question.
831
+ await refreshLedger(opened.o.loaded.file);
827
832
  const read = await readWithLedger(opened.o);
828
833
  const target = read.before.find((e) => e.id === id);
829
834
  if (target && target.data !== null) return { opened, before: read.before, read, target: target as FoundQuestion["target"] };
@@ -304,7 +304,7 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
304
304
  if (!("value" in response)) return print(response);
305
305
  ask = responseAsk(response.value);
306
306
  }
307
- return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, dryRun: args.dryRun }));
307
+ return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, ...(args.store ? { store: args.store } : {}), dryRun: args.dryRun }));
308
308
  }
309
309
  if (verb === "answer") {
310
310
  const id = args.extraPositional2;