@brainervirus/workit-core 8.7.0 → 8.9.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.
@@ -11,9 +11,6 @@
11
11
  // A session is nudged about one skill at most once (a marker per session
12
12
  // in the workspace store), never in a subagent, never once it loaded the skill.
13
13
  // Every failure answers "no nudge": hooks fail open.
14
- import { createHash } from "node:crypto";
15
- import fs from "node:fs";
16
- import path from "node:path";
17
14
  import {
18
15
  skillLoadWording,
19
16
  WORKIT_METHOD_SKILLS,
@@ -22,9 +19,9 @@ import {
22
19
  type WorkitSkill,
23
20
  } from "../core/skill-manifests";
24
21
  import { appendHookObserved, readLedger } from "../ledger";
25
- import { resolveStore } from "../store/paths";
26
22
  import type { HookDecision, HookInput, HostId } from "./protocol";
27
- import { inWorkitWorkspace, rawDelivery, rawGitPre } from "./raw-git";
23
+ import { inWorkitWorkspace, rawDelivery, rawGitPre, workitInvocations } from "./raw-git";
24
+ import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
28
25
  import { segmentsOf, type ShellDialect } from "./shell-words";
29
26
 
30
27
  /** Skills the user starts; a nudge says to offer them, never to load them. */
@@ -105,35 +102,6 @@ export const promptTrigger = (prompt: string): { skill: WorkitSkill; trigger: st
105
102
  return null;
106
103
  };
107
104
 
108
- /** Per-session nudge state: skills nudged or loaded, so each nudges once. */
109
- type Marker = { nudged: string[]; loaded: string[] };
110
-
111
- /** The marker in the workspace's store (null outside one: nothing is nudged or recorded there). */
112
- const markerFile = (input: HookInput, session: string): string | null => {
113
- const location = resolveStore(input.cwd);
114
- if (location instanceof Error || !location.shared) return null;
115
- const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
116
- return path.join(location.dir, "hooks", `skills-${name}.json`);
117
- };
118
-
119
- const readMarker = (file: string): Marker => {
120
- try {
121
- const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Marker>;
122
- return { nudged: value.nudged ?? [], loaded: value.loaded ?? [] };
123
- } catch {
124
- return { nudged: [], loaded: [] };
125
- }
126
- };
127
-
128
- const writeMarker = (file: string, marker: Marker): void => {
129
- try {
130
- fs.mkdirSync(path.dirname(file), { recursive: true });
131
- fs.writeFileSync(file, JSON.stringify(marker));
132
- } catch {
133
- // Fail open: without a marker a later nudge repeats.
134
- }
135
- };
136
-
137
105
  /** Skills this session already loaded, from its `skill.loaded` rows. */
138
106
  const loadedSkills = (cwd: string, session: string): Set<string> => {
139
107
  const ledger = readLedger(cwd);
@@ -194,11 +162,11 @@ export function recordSkillLoad(
194
162
  const session = input.session.id;
195
163
  if (!skill || !session) return;
196
164
  if (!inWorkitWorkspace(input.cwd)) return;
197
- const file = markerFile(input, session);
165
+ const file = sessionMarkerFile(input, session);
198
166
  if (!file) return;
199
- const marker = readMarker(file);
167
+ const marker = readSessionMarker(file);
200
168
  if (marker.loaded.includes(skill)) return;
201
- writeMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
169
+ writeSessionMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
202
170
  if (loadedSkills(input.cwd, session).has(skill)) return;
203
171
  appendHookObserved(input.cwd, {
204
172
  type: "skill.loaded",
@@ -218,12 +186,12 @@ const nudgeOnce = (
218
186
  skill: WorkitSkill,
219
187
  line: string,
220
188
  ): string | null => {
221
- const file = markerFile(input, session);
189
+ const file = sessionMarkerFile(input, session);
222
190
  if (!file) return null;
223
- const marker = readMarker(file);
191
+ const marker = readSessionMarker(file);
224
192
  if (marker.nudged.includes(skill) || marker.loaded.includes(skill)) return null;
225
193
  if (loadedSkills(input.cwd, session).has(skill)) return null;
226
- writeMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
194
+ writeSessionMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
227
195
  return line;
228
196
  };
229
197
 
@@ -240,11 +208,22 @@ const promptLoads = (prompt: string): string | null =>
240
208
  /^\s*Load the (workit-[a-z-]+) skill with the skill tool\b/.exec(prompt)?.[1] ??
241
209
  null;
242
210
 
211
+ /**
212
+ * A prompt that relays another agent's words, not the user's: Claude Code
213
+ * delivers subagent hand-backs, teammate messages and background-task
214
+ * notifications through UserPromptSubmit. A reviewer's report that says
215
+ * "regressed" is not a request for workit-debug.
216
+ */
217
+ export const relayedMessage = (prompt: string): boolean =>
218
+ /^\s*(?:Another Claude session sent a message\b|<agent-message\s+from=|\[SYSTEM NOTIFICATION\b|<task-notification>)/.test(
219
+ prompt,
220
+ );
221
+
243
222
  /** A main session's prompt in a Workit workspace: one advisory line naming the skill it asks for. */
244
223
  export function promptNudge(input: HookInput, prompt: string | null | undefined): string | null {
245
224
  try {
246
225
  const session = input.session.id;
247
- if (!prompt || !session || input.session.agentId) return null;
226
+ if (!prompt || !session || input.session.agentId || relayedMessage(prompt)) return null;
248
227
  const loads = promptLoads(prompt);
249
228
  if (loads) {
250
229
  // The prompt already loads its skill; record it, never nudge.
@@ -265,35 +244,13 @@ export function promptNudge(input: HookInput, prompt: string | null | undefined)
265
244
  }
266
245
 
267
246
  const WORKIT_DELIVERY = /^(?:pr (?:create|merge)|ci wait|git push)\b/;
268
- const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
269
- const LAUNCHERS = new Set([
270
- "npx",
271
- "bunx",
272
- "pnpx",
273
- "-y",
274
- "--yes",
275
- "pnpm",
276
- "dlx",
277
- "exec",
278
- "env",
279
- "sudo",
280
- "command",
281
- ]);
282
-
283
- /** Does a segment list run a workit delivery verb (also inside `bash -c '…'`)? */
284
- const runsWorkitDelivery = (command: string, dialect: ShellDialect, depth = 0): boolean =>
285
- segmentsOf(command, dialect).some(({ words }) => {
286
- // The command word, past env assignments and package runners (`npx -y`).
287
- const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
288
- if (at >= 0 && /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/.test(words[at])) {
289
- const args = words.slice(at + 1);
290
- if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run")) return false;
291
- return WORKIT_DELIVERY.test(args.join(" "));
292
- }
293
- const shell = SHELLS.has(words[at]?.split(/[\\/]/).at(-1) ?? "");
294
- const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
295
- return depth < 3 && script !== undefined && runsWorkitDelivery(script, "posix", depth + 1);
296
- });
247
+ /** Does the command run a workit delivery verb (also inside `bash -c '…'`), never help or a dry run? */
248
+ const runsWorkitDelivery = (command: string, dialect: ShellDialect): boolean =>
249
+ workitInvocations(command, dialect).some(
250
+ (args) =>
251
+ !args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run") &&
252
+ WORKIT_DELIVERY.test(args.join(" ")),
253
+ );
297
254
 
298
255
  /**
299
256
  * Does the command deliver in a Workit workspace: a push, a PR/MR create or
@@ -0,0 +1,308 @@
1
+ // Stop control (ledger decision "stop control: option A, nudge once"): when
2
+ // the main agent ends its turn with an obligation the ledger or git can
3
+ // prove unmet, the host continues it once with a reason naming the
4
+ // obligation and the workit command that clears it. The obligations, for the
5
+ // session's branch (the checked-out branch, on which this session recorded a
6
+ // commit or opened a PR):
7
+ // (a) unpushed: a commit this session recorded, not on the push remote,
8
+ // while the effective endpoint is pr, green or merged;
9
+ // (b) checks: the branch's open PR has failing or pending checks recorded
10
+ // for its current head (`pr.status` rows) while the endpoint is green
11
+ // or merged;
12
+ // (c) verdict: the session opened the PR (or verified delivery) but the
13
+ // head carries no accepted non-author verdict.
14
+ // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
15
+ // `pr status`/`ci wait` seeing the forge report it merged) of HEAD or of a
16
+ // head that contains this session's commits, or, with no merge recorded, an
17
+ // upstream that is gone. Its remote branch may be deleted, and an
18
+ // `--unverified` merge was the user's call.
19
+ // A stop is never blocked: in a subagent, outside a Workit workspace, when
20
+ // the host says this stop already follows a continuation (one continue per
21
+ // turn), or when the agent's last message asks the user a question. Reads
22
+ // the ledger and local git only (no forge call), and every failure allows
23
+ // the stop.
24
+ import { spawnSync } from "node:child_process";
25
+ import { resolveAutonomy } from "../autonomy";
26
+ import { currentBranch, pushRemoteName } from "../git/rev";
27
+ import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
28
+ import type { HostDescriptor } from "./descriptor";
29
+ import type { HookDecision, HookInput } from "./protocol";
30
+ import { guardedBranch, inWorkitWorkspace } from "./raw-git";
31
+
32
+ const NONE: HookDecision = { kind: "none" };
33
+
34
+ export type StopObligation = {
35
+ kind: "unpushed" | "checks" | "verdict";
36
+ /** What is left, with the workit command that clears it. */
37
+ reason: string;
38
+ };
39
+
40
+ /** A question mark (Latin, full-width, Arabic) ending a sentence, or Spanish's opening one. */
41
+ const QUESTION = /[??؟](?=[\s*_`"')\]>]*(?:\s|$))|¿/;
42
+ /** An offer or a request for permission. */
43
+ const OFFER =
44
+ /\b(?:let me know|want me to|should I|shall I|would you like|do you want|if you(?:'d| would) like|may I|can I go ahead)\b/i;
45
+ /** A `[y/N]`, `(y/n)` or `[Y/n]` prompt. */
46
+ const YES_NO = /[[(]\s*y(?:es)?\s*\/\s*n(?:o)?\s*[\])]/i;
47
+ /** A list item: `- a`, `* a`, `1. a`, `2) a`, `a) a`. */
48
+ const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)]|[a-z][.)])\s+/i;
49
+
50
+ /**
51
+ * Does the message end by asking the user something? The last two
52
+ * paragraphs (past code blocks and a trailing option list) are read for a
53
+ * question mark, an offer ("want me to", "shall I", "let me know") or a
54
+ * yes/no prompt: a stop that waits on the user is never blocked, so this
55
+ * errs towards "yes".
56
+ */
57
+ export const asksUser = (message: string | null | undefined): boolean => {
58
+ if (!message) return false;
59
+ const paragraphs = message
60
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n\n")
61
+ .split(/\n\s*\n/)
62
+ .map((paragraph) => paragraph.trim())
63
+ .filter(Boolean);
64
+ // A trailing option list belongs to the question before it.
65
+ while (
66
+ paragraphs.length > 1 &&
67
+ paragraphs
68
+ .at(-1)!
69
+ .split("\n")
70
+ .every((line) => LIST_ITEM.test(line))
71
+ )
72
+ paragraphs.pop();
73
+ const tail = paragraphs.slice(-2).join("\n");
74
+ return QUESTION.test(tail) || OFFER.test(tail) || YES_NO.test(tail);
75
+ };
76
+
77
+ const short = (sha: string | null | undefined) => (sha ?? "?").slice(0, 12);
78
+
79
+ /** A git command's output lines, or null when it fails. */
80
+ const gitList = (cwd: string, args: string[]): string[] | null => {
81
+ const run = spawnSync("git", args, {
82
+ cwd,
83
+ encoding: "utf8",
84
+ timeout: 3_000,
85
+ windowsHide: true,
86
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
87
+ });
88
+ return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : null;
89
+ };
90
+
91
+ const gitLine = (cwd: string, args: string[]): string | null => gitList(cwd, args)?.[0] ?? null;
92
+
93
+ const sessionOf = (row: ReadRow): string | null =>
94
+ (typeof row.session === "string" ? row.session : null) ?? row.actor.session;
95
+
96
+ /**
97
+ * The branch's open PR: the newest one the CLI opened or observed
98
+ * (`pr.created`, `pr.status`), unless it was merged since.
99
+ */
100
+ /** A `pr.status` row's PR state; rows written before it was recorded are open. */
101
+ const prState = (row: ReadRow): string => (typeof row.state === "string" ? row.state : "open");
102
+
103
+ /** A CLI row saying the PR ended: `workit pr merge`, or the forge reporting it merged or closed. */
104
+ const ended = (row: ReadRow): boolean =>
105
+ row.observer === "workit_cli" &&
106
+ (row.type === "pr.merged" || (row.type === "pr.status" && prState(row) !== "open"));
107
+
108
+ const openPr = (rows: readonly ReadRow[]): number | null => {
109
+ const seen = rows.findLast(
110
+ (row) =>
111
+ (row.type === "pr.created" || row.type === "pr.status") &&
112
+ row.observer === "workit_cli" &&
113
+ typeof row.pr === "number",
114
+ );
115
+ if (!seen || ended(seen)) return null;
116
+ const closed = rows.some((row) => ended(row) && row.pr === seen.pr && row.seq > seen.seq);
117
+ return closed ? null : (seen.pr as number);
118
+ };
119
+
120
+ /**
121
+ * The heads the ledger records as merged: `workit pr merge` (`pr.merged`), or
122
+ * the forge reporting the PR merged (`pr.status` state merged, as `workit pr
123
+ * status` and `workit ci wait` record a merge done in the forge UI). A PR
124
+ * closed without merging is not one. Newest last, at most three.
125
+ */
126
+ const mergedHeads = (rows: readonly ReadRow[]): string[] =>
127
+ [
128
+ ...new Set(
129
+ rows
130
+ .filter(
131
+ (row) =>
132
+ row.observer === "workit_cli" &&
133
+ typeof row.head === "string" &&
134
+ (row.type === "pr.merged" || (row.type === "pr.status" && prState(row) === "merged")),
135
+ )
136
+ .map((row) => row.head as string),
137
+ ),
138
+ ].slice(-3);
139
+
140
+ /** Is `sha` a commit in the local object store? */
141
+ const localCommit = (cwd: string, sha: string): boolean =>
142
+ /^[0-9a-f]{7,64}$/.test(sha) && gitList(cwd, ["cat-file", "-e", `${sha}^{commit}`]) !== null;
143
+
144
+ /** The branch's configured upstream was deleted on the remote and pruned. */
145
+ const upstreamGone = (cwd: string, branch: string): boolean =>
146
+ gitLine(cwd, ["for-each-ref", "--format=%(upstream:track)", `refs/heads/${branch}`]) === "[gone]";
147
+
148
+ /**
149
+ * Has the branch landed? A recorded merge (`mergedHeads`) of the current
150
+ * HEAD, or of a head that already contains every commit of this session on
151
+ * HEAD: the merge record and the branch both say done, so nothing is left to
152
+ * push (the remote branch may be deleted) or to verify (an `--unverified`
153
+ * merge was the user's call). With no merge recorded, an upstream that is
154
+ * gone (the forge deleted the merged branch and a fetch pruned it) says the
155
+ * same. New commits of this session after a recorded merge are not landed.
156
+ */
157
+ const landed = (
158
+ cwd: string,
159
+ branch: string,
160
+ rows: readonly ReadRow[],
161
+ head: string | null,
162
+ mine: ReadonlySet<string>,
163
+ ): boolean => {
164
+ if (!head) return false;
165
+ const merged = mergedHeads(rows);
166
+ if (merged.length === 0) return upstreamGone(cwd, branch);
167
+ if (merged.includes(head)) return true;
168
+ if (mine.size === 0) return false;
169
+ return merged.some((at) => {
170
+ // Commits on HEAD the merged head lacks; null when it is not a local commit.
171
+ const after = gitList(cwd, ["rev-list", `--max-count=${UNPUSHED_SCAN}`, "HEAD", "--not", at]);
172
+ return after !== null && !after.some((sha) => mine.has(sha));
173
+ });
174
+ };
175
+
176
+ /** How far back (a) looks for unpushed commits. */
177
+ const UNPUSHED_SCAN = 500;
178
+
179
+ /**
180
+ * (a) This session's recorded commits on HEAD that neither the push remote
181
+ * nor a recorded merge holds. Another session's or the user's commits never
182
+ * count, nor do commits already merged before the branch was reused.
183
+ */
184
+ const unpushed = (
185
+ cwd: string,
186
+ branch: string,
187
+ endpoint: string,
188
+ mine: ReadonlySet<string>,
189
+ merged: readonly string[],
190
+ ): StopObligation | null => {
191
+ const remote = pushRemoteName(cwd, branch);
192
+ if (!remote || mine.size === 0) return null;
193
+ const listed = gitList(cwd, [
194
+ "rev-list",
195
+ `--max-count=${UNPUSHED_SCAN}`,
196
+ "HEAD",
197
+ "--not",
198
+ `--remotes=${remote}`,
199
+ ...merged.filter((sha) => localCommit(cwd, sha)),
200
+ ]);
201
+ const count = (listed ?? []).filter((sha) => mine.has(sha)).length;
202
+ if (count === 0) return null;
203
+ return {
204
+ kind: "unpushed",
205
+ reason: `${branch} has ${count} commit(s) of this session not on ${remote}, and this workspace's endpoint is \`${endpoint}\` (\`workit grant show\`): push them with \`workit git push\`${endpoint === "pr" ? ", then open or update the PR (`workit pr create --fill`)" : ""}.`,
206
+ };
207
+ };
208
+
209
+ /** (b) The open PR's checks, as last recorded for the current head. */
210
+ const redChecks = (
211
+ rows: readonly ReadRow[],
212
+ pr: number,
213
+ head: string,
214
+ endpoint: string,
215
+ ): StopObligation | null => {
216
+ const status = rows.findLast(
217
+ (row) => row.type === "pr.status" && row.observer === "workit_cli" && row.pr === pr,
218
+ );
219
+ const state = status && status.head === head && prState(status) === "open" ? status.checks : null;
220
+ if (state !== "failing" && state !== "pending") return null;
221
+ return {
222
+ kind: "checks",
223
+ reason: `PR #${pr}'s checks were last seen ${state} at ${short(head)}, and this workspace's endpoint is \`${endpoint}\`: run \`workit ci wait --pr ${pr}\`${state === "failing" ? " after fixing the failures `workit pr status` lists (then push)" : ""}.`,
224
+ };
225
+ };
226
+
227
+ /** (c) Delivered by this session, yet no accepted non-author verdict on the head. */
228
+ const unverified = (
229
+ cwd: string,
230
+ branch: string,
231
+ all: readonly ReadRow[],
232
+ descriptor: HostDescriptor,
233
+ ): StopObligation | null => {
234
+ const check = checkVerdicts(cwd, branch, all);
235
+ if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
236
+ const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
237
+ const verifier = `${descriptor.subagents.agentPrefix}verifier`;
238
+ return {
239
+ kind: "verdict",
240
+ reason: `the head ${short(check.head)} of ${branch}, which this session delivered, has ${has}: if no verifier is already running on it, hand it to a session that did not author it (the ${verifier} agent records \`workit ledger verdict <result> --branch ${branch} --how "<evidence>"\`), or tell the user it is unverified. Never record a verdict on your own work.`,
241
+ };
242
+ };
243
+
244
+ /** The first unmet obligation for the session's branch, or null. Never throws. */
245
+ export function stopObligation(
246
+ input: HookInput,
247
+ descriptor: HostDescriptor,
248
+ ): StopObligation | null {
249
+ try {
250
+ const session = input.session.id;
251
+ // Outside a Workit workspace there is no ledger either; the check only
252
+ // spares every other repository's Stop the git and ledger reads below.
253
+ if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
254
+ const branch = currentBranch(input.cwd);
255
+ if (!branch || guardedBranch(input.cwd, branch)) return null;
256
+ const ledger = readLedger(input.cwd);
257
+ if (!ledger.ok) return null;
258
+ const all = ledger.value.rows;
259
+ const rows = all.filter((row) => row.branch === branch);
260
+ const mine = rows.filter((row) => sessionOf(row) === session);
261
+ const commits = new Set(
262
+ mine
263
+ .filter((row) => row.type === "commit.recorded")
264
+ .map((row) => (typeof row.sha === "string" ? row.sha : row.head))
265
+ .filter((sha): sha is string => typeof sha === "string"),
266
+ );
267
+ const delivered = mine.some(
268
+ (row) =>
269
+ row.observer === "workit_cli" &&
270
+ (row.type === "pr.created" || row.type === "delivery.verified"),
271
+ );
272
+ if (commits.size === 0 && !delivered) return null;
273
+ const head = gitLine(input.cwd, ["rev-parse", "HEAD"]);
274
+ if (landed(input.cwd, branch, rows, head, commits)) return null;
275
+ const endpoint = resolveAutonomy(input.cwd).effectiveEndpoint;
276
+ if (endpoint !== "commit") {
277
+ const left = unpushed(input.cwd, branch, endpoint, commits, mergedHeads(rows));
278
+ if (left) return left;
279
+ }
280
+ const pr = openPr(rows);
281
+ if ((endpoint === "green" || endpoint === "merged") && pr !== null && head) {
282
+ const left = redChecks(rows, pr, head, endpoint);
283
+ if (left) return left;
284
+ }
285
+ return delivered ? unverified(input.cwd, branch, all, descriptor) : null;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The stop decision: continue once with the obligation, or allow. A stop
293
+ * that follows a continuation (`stopHookActive`), a subagent's stop, and a
294
+ * stop whose last message asks the user something are always allowed. So is
295
+ * a stop whose last message is unknown: it may be a question.
296
+ */
297
+ export function stopDecision(input: HookInput, descriptor: HostDescriptor): HookDecision {
298
+ const event = input.event;
299
+ if (event.kind !== "stop" || event.stopHookActive) return NONE;
300
+ if (!event.lastMessage?.trim() || asksUser(event.lastMessage)) return NONE;
301
+ const left = stopObligation(input, descriptor);
302
+ return left
303
+ ? {
304
+ kind: "continue",
305
+ reason: `Workit: before stopping, ${left.reason} If this cannot be done now, say so to the user and stop.`,
306
+ }
307
+ : NONE;
308
+ }
package/src/ledger.ts CHANGED
@@ -1801,6 +1801,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
1801
1801
  case "pr.created":
1802
1802
  summary = `#${row.pr ?? "?"} -> ${text("base")} at ${(row.head ?? "?").slice(0, 12)}${row.created === false ? " (existing)" : ""}`;
1803
1803
  break;
1804
+ case "pr.status":
1805
+ summary = `#${row.pr ?? "?"} ${text("state") && text("state") !== "open" ? `${text("state")} ` : ""}checks ${text("checks") || "?"} at ${(row.head ?? "?").slice(0, 12)}`;
1806
+ break;
1804
1807
  case "pr.merged":
1805
1808
  summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
1806
1809
  break;