portable-agent-layer 0.85.6 → 0.85.7
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/assets/schema/pal-settings.schema.json +8 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/hooks/StopOrchestrator.ts +14 -13
- package/src/hooks/lib/agent.ts +7 -0
- package/src/hooks/lib/claim-check.ts +122 -0
- package/src/hooks/lib/claim-log.ts +131 -0
- package/src/hooks/lib/stop.ts +2 -0
- package/src/hooks/lib/transcript.ts +1 -1
- package/src/tools/agent/interaction.ts +10 -4
- package/src/tools/lib/interaction-report.ts +17 -0
|
@@ -82,6 +82,14 @@
|
|
|
82
82
|
"type": "boolean",
|
|
83
83
|
"description": "Keep a rolling local sample (about 230 messages) of user message text, its reaction label and the end of the reply it answered, in memory/state/reaction-samples.json, so the reaction rules can be audited. Stores message text; set false to keep none."
|
|
84
84
|
},
|
|
85
|
+
"claimCheck": {
|
|
86
|
+
"type": "boolean",
|
|
87
|
+
"description": "At each stop, check whether the final reply claims a result (tests pass, CI green, works now) that no command in that turn could have shown, and log the verdict to memory/signals/claim-checks/. Stores the claiming sentence only when nothing backed it."
|
|
88
|
+
},
|
|
89
|
+
"claimCheckBlocks": {
|
|
90
|
+
"type": "boolean",
|
|
91
|
+
"description": "Opt-in, off by default. Send a reply with an unbacked result claim back to the agent once, asking it to show the output or say the result is unverified. Needs claimCheck. Not on Cursor, whose stop payload carries no reply text."
|
|
92
|
+
},
|
|
85
93
|
"steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
|
|
86
94
|
}
|
|
87
95
|
},
|
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
9
|
-
import {
|
|
9
|
+
import { stopBlockResponse } from "./lib/agent";
|
|
10
|
+
import { watchClaims } from "./lib/claim-log";
|
|
10
11
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
11
12
|
import { logError } from "./lib/log";
|
|
12
13
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
@@ -24,21 +25,21 @@ try {
|
|
|
24
25
|
// is worth less than not blocking at all — require the reason to raise one.
|
|
25
26
|
const decision = checkReadmeSync();
|
|
26
27
|
if (decision.decision === "block" && decision.reason) {
|
|
27
|
-
|
|
28
|
-
// Cursor stop hook: followup_message auto-sends to the agent
|
|
29
|
-
process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
|
|
30
|
-
} else if (isCodex()) {
|
|
31
|
-
// Codex stop hook: additionalContext re-queues as next prompt
|
|
32
|
-
process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
|
|
33
|
-
} else {
|
|
34
|
-
// Claude Code, the Copilot CLI and VS Code's own Copilot each read a
|
|
35
|
-
// different stop-block shape; VS Code ignores the top-level keys entirely.
|
|
36
|
-
process.stdout.write(blockResponse(decision.reason, "Stop"));
|
|
37
|
-
}
|
|
28
|
+
process.stdout.write(stopBlockResponse(decision.reason));
|
|
38
29
|
process.exit(0);
|
|
39
30
|
}
|
|
40
31
|
} catch (err) {
|
|
41
32
|
logError("StopOrchestrator:readme-sync", err);
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
|
|
35
|
+
const payload = await readStdinJSON<StopTurnPayload>();
|
|
36
|
+
try {
|
|
37
|
+
const sendBack = watchClaims(payload);
|
|
38
|
+
if (sendBack) {
|
|
39
|
+
process.stdout.write(stopBlockResponse(sendBack));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logError("StopOrchestrator:claim-check", err);
|
|
44
|
+
}
|
|
45
|
+
await stopTurn(payload);
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -267,6 +267,13 @@ export function promptContextResponse(context: string): string | null {
|
|
|
267
267
|
return `${context}\n`;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/** Stops the agent from ending its turn and hands it the reason as its next input. */
|
|
271
|
+
export function stopBlockResponse(reason: string): string {
|
|
272
|
+
if (isCursor()) return JSON.stringify({ followup_message: reason });
|
|
273
|
+
if (isCodex()) return JSON.stringify({ additionalContext: reason });
|
|
274
|
+
return blockResponse(reason, "Stop");
|
|
275
|
+
}
|
|
276
|
+
|
|
270
277
|
export function blockResponse(reason: string, hookEventName?: string): string {
|
|
271
278
|
if (isCursor()) {
|
|
272
279
|
return JSON.stringify({ permission: "deny", user_message: reason });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a reply claims a result (tests pass, CI green, it works now) that no
|
|
3
|
+
* command in the same turn could have shown. Precision over recall: a hedged or
|
|
4
|
+
* negated sentence is never a claim, and an unreadable turn is never unbacked.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isSystemText } from "./prompt-text";
|
|
8
|
+
import { isCodexInjectedContext } from "./transcript";
|
|
9
|
+
|
|
10
|
+
const CLAIM_RE =
|
|
11
|
+
/\b(?:tests?|suite|ci|checks?|builds?|gates?|pipeline)\b.*\b(?:pass(?:es|ed)?|green|succeed(?:s|ed)?)\b|\bverified\b|\b(?:works|working) now\b|\bnow works\b/i;
|
|
12
|
+
const HEDGED_RE =
|
|
13
|
+
/\b(?:not|no longer|fail(?:s|ed|ing)?|until|when|if|unless|should|would|will|might|could|may)\b|n't\b/i;
|
|
14
|
+
|
|
15
|
+
const COMMAND_TOOLS = new Set([
|
|
16
|
+
"Bash",
|
|
17
|
+
"BashOutput",
|
|
18
|
+
"Monitor",
|
|
19
|
+
"PowerShell",
|
|
20
|
+
"exec",
|
|
21
|
+
"shell",
|
|
22
|
+
"exec_command",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export type ClaimVerdict = "none" | "backed" | "unbacked" | "unknown";
|
|
26
|
+
|
|
27
|
+
export interface ClaimCheck {
|
|
28
|
+
verdict: ClaimVerdict;
|
|
29
|
+
claims: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sentences(reply: string): string[] {
|
|
33
|
+
return reply
|
|
34
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
35
|
+
.map((s) => s.trim().replace(/[.!?]+$/, ""))
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A quoted claim is being talked about, not made. */
|
|
40
|
+
function withoutQuotes(sentence: string): string {
|
|
41
|
+
return sentence.replace(/"[^"]*"|“[^”]*”|`[^`]*`/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resultClaims(reply: string): string[] {
|
|
45
|
+
return sentences(reply).filter((s) => {
|
|
46
|
+
const own = withoutQuotes(s);
|
|
47
|
+
return CLAIM_RE.test(own) && !HEDGED_RE.test(own);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TranscriptLine {
|
|
52
|
+
type?: string;
|
|
53
|
+
message?: { content?: unknown };
|
|
54
|
+
payload?: { type?: string; role?: string; name?: string; content?: unknown };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsed(line: string): TranscriptLine | null {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function blocks(content: unknown): { type?: string; name?: string; text?: string }[] {
|
|
66
|
+
return Array.isArray(content) ? content : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function promptText(entry: TranscriptLine): string | null {
|
|
70
|
+
if (entry.type === "user") {
|
|
71
|
+
const content = entry.message?.content;
|
|
72
|
+
if (typeof content === "string") return content;
|
|
73
|
+
const texts = blocks(content).filter((b) => b.type === "text");
|
|
74
|
+
return texts.length ? texts.map((b) => b.text ?? "").join(" ") : null;
|
|
75
|
+
}
|
|
76
|
+
if (entry.type === "response_item" && entry.payload?.role === "user") {
|
|
77
|
+
return blocks(entry.payload.content)
|
|
78
|
+
.map((b) => b.text ?? "")
|
|
79
|
+
.join(" ");
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isUserPrompt(entry: TranscriptLine): boolean {
|
|
85
|
+
const text = promptText(entry);
|
|
86
|
+
return text !== null && !isSystemText(text) && !isCodexInjectedContext(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commandsIn(entry: TranscriptLine): number {
|
|
90
|
+
if (entry.type === "assistant")
|
|
91
|
+
return blocks(entry.message?.content).filter(
|
|
92
|
+
(b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? "")
|
|
93
|
+
).length;
|
|
94
|
+
const call = entry.payload;
|
|
95
|
+
if (entry.type !== "response_item" || !call) return 0;
|
|
96
|
+
if (call.type === "local_shell_call") return 1;
|
|
97
|
+
const isCall = call.type === "function_call" || call.type === "custom_tool_call";
|
|
98
|
+
return isCall && COMMAND_TOOLS.has(call.name ?? "") ? 1 : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isTurnEntry(entry: TranscriptLine): boolean {
|
|
102
|
+
return ["user", "assistant", "response_item"].includes(entry.type ?? "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Null when no line reads as a turn, so an unknown transcript format never counts as zero. */
|
|
106
|
+
export function commandsThisTurn(lines: string[]): number | null {
|
|
107
|
+
const entries = lines.map(parsed).filter((e): e is TranscriptLine => e !== null);
|
|
108
|
+
if (!entries.some(isTurnEntry)) return null;
|
|
109
|
+
let commands = 0;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (isUserPrompt(entry)) commands = 0;
|
|
112
|
+
commands += commandsIn(entry);
|
|
113
|
+
}
|
|
114
|
+
return commands;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function checkClaims(reply: string, commands: number | null): ClaimCheck {
|
|
118
|
+
const claims = resultClaims(reply);
|
|
119
|
+
if (claims.length === 0) return { verdict: "none", claims };
|
|
120
|
+
if (commands === null) return { verdict: "unknown", claims };
|
|
121
|
+
return { verdict: commands > 0 ? "backed" : "unbacked", claims };
|
|
122
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim check at stop: every reply that makes a result claim is logged to
|
|
3
|
+
* memory/signals/claim-checks/ with its verdict. The claiming sentence is kept
|
|
4
|
+
* only when no command backed it, so the log can be audited. Sending the reply
|
|
5
|
+
* back is opt-in (dynamicContext.claimCheckBlocks).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { currentAttribution } from "./actor";
|
|
11
|
+
import {
|
|
12
|
+
type ClaimCheck,
|
|
13
|
+
type ClaimVerdict,
|
|
14
|
+
checkClaims,
|
|
15
|
+
commandsThisTurn,
|
|
16
|
+
} from "./claim-check";
|
|
17
|
+
import { hookFinalReply, hookSessionId } from "./hook-turn";
|
|
18
|
+
import { ensureDir, paths } from "./paths";
|
|
19
|
+
import { isEnabled, isOptedIn } from "./settings";
|
|
20
|
+
import type { StopTurnPayload } from "./stop";
|
|
21
|
+
|
|
22
|
+
const CLAIM_MAX = 200;
|
|
23
|
+
|
|
24
|
+
export interface ClaimRecord {
|
|
25
|
+
ts: string;
|
|
26
|
+
session: string;
|
|
27
|
+
runtime?: string;
|
|
28
|
+
verdict: ClaimVerdict;
|
|
29
|
+
commands: number | null;
|
|
30
|
+
claims?: string[];
|
|
31
|
+
blocked?: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function claimsDir(): string {
|
|
35
|
+
return ensureDir(resolve(paths.signals(), "claim-checks"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function monthFile(date: Date): string {
|
|
39
|
+
return resolve(claimsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function transcriptLines(payload: StopTurnPayload): string[] {
|
|
43
|
+
const path = payload.transcript_path ?? payload.transcriptPath;
|
|
44
|
+
if (!path || !existsSync(path)) return [];
|
|
45
|
+
return readFileSync(path, "utf-8").split("\n").filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function needsAudit(verdict: ClaimVerdict): boolean {
|
|
49
|
+
return verdict === "unbacked" || verdict === "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Watched {
|
|
53
|
+
check: ClaimCheck;
|
|
54
|
+
commands: number | null;
|
|
55
|
+
session: string;
|
|
56
|
+
blocked: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function record({ check, commands, session, blocked }: Watched, now: Date) {
|
|
60
|
+
const line: ClaimRecord = {
|
|
61
|
+
ts: now.toISOString(),
|
|
62
|
+
session,
|
|
63
|
+
runtime: currentAttribution().runtime,
|
|
64
|
+
verdict: check.verdict,
|
|
65
|
+
commands,
|
|
66
|
+
...(needsAudit(check.verdict) && {
|
|
67
|
+
claims: check.claims.map((c) => c.slice(0, CLAIM_MAX)),
|
|
68
|
+
}),
|
|
69
|
+
...(blocked && { blocked }),
|
|
70
|
+
};
|
|
71
|
+
appendFileSync(monthFile(now), `${JSON.stringify(line)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function alreadySentBack(payload: StopTurnPayload): boolean {
|
|
75
|
+
return payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sendsBack(payload: StopTurnPayload, check: ClaimCheck): boolean {
|
|
79
|
+
return (
|
|
80
|
+
check.verdict === "unbacked" &&
|
|
81
|
+
isOptedIn("claimCheckBlocks") &&
|
|
82
|
+
!alreadySentBack(payload)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sendBackReason(claims: string[]): string {
|
|
87
|
+
const quoted = claims.map((c) => `"${c.slice(0, CLAIM_MAX)}"`).join(", ");
|
|
88
|
+
return `Your reply claims a result no command in this turn showed: ${quoted}. Run the check and show its output, or say it is unverified.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks the final reply against the turn's commands and logs any result claim.
|
|
93
|
+
* Returns the reason to send the reply back with, when that is opted in.
|
|
94
|
+
*/
|
|
95
|
+
export function watchClaims(
|
|
96
|
+
payload: StopTurnPayload | null,
|
|
97
|
+
now: Date = new Date()
|
|
98
|
+
): string | null {
|
|
99
|
+
if (!payload || !isEnabled("claimCheck")) return null;
|
|
100
|
+
const reply = hookFinalReply(payload);
|
|
101
|
+
if (!reply) return null;
|
|
102
|
+
const commands = commandsThisTurn(transcriptLines(payload));
|
|
103
|
+
const check = checkClaims(reply, commands);
|
|
104
|
+
if (check.verdict === "none") return null;
|
|
105
|
+
const blocked = sendsBack(payload, check);
|
|
106
|
+
record({ check, commands, session: hookSessionId(payload) ?? "unknown", blocked }, now);
|
|
107
|
+
return blocked ? sendBackReason(check.claims) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readRecords(file: string): ClaimRecord[] {
|
|
111
|
+
return readFileSync(file, "utf-8")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.flatMap((line) => {
|
|
115
|
+
try {
|
|
116
|
+
return [JSON.parse(line) as ClaimRecord];
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function claimChecksSince(since: Date): ClaimRecord[] {
|
|
124
|
+
const fromMonth = since.toISOString().slice(0, 7);
|
|
125
|
+
const sinceTs = since.toISOString();
|
|
126
|
+
return readdirSync(claimsDir())
|
|
127
|
+
.filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
|
|
128
|
+
.sort()
|
|
129
|
+
.flatMap((f) => readRecords(resolve(claimsDir(), f)))
|
|
130
|
+
.filter((r) => r.ts >= sinceTs);
|
|
131
|
+
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
export interface StopTurnPayload extends HookTurnPayload {
|
|
42
42
|
transcript_path?: string | null;
|
|
43
43
|
transcriptPath?: string | null;
|
|
44
|
+
stop_hook_active?: boolean;
|
|
45
|
+
loop_count?: number;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
type Transcript = ReturnType<typeof readTranscriptFile>;
|
|
@@ -38,7 +38,7 @@ interface CodexPayload {
|
|
|
38
38
|
|
|
39
39
|
const CODEX_INJECTED_CONTEXT = ["# AGENTS.md instructions", "<environment_context>"];
|
|
40
40
|
|
|
41
|
-
function isCodexInjectedContext(text: string): boolean {
|
|
41
|
+
export function isCodexInjectedContext(text: string): boolean {
|
|
42
42
|
return CODEX_INJECTED_CONTEXT.some((prefix) => text.startsWith(prefix));
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
|
+
import { claimChecksSince } from "../../hooks/lib/claim-log";
|
|
10
11
|
import { turnsSince } from "../../hooks/lib/interaction";
|
|
11
|
-
import { reportLines, summarize } from "../lib/interaction-report";
|
|
12
|
+
import { claimCheckLines, reportLines, summarize } from "../lib/interaction-report";
|
|
12
13
|
import { scriptArgs } from "../lib/script-args";
|
|
13
14
|
|
|
14
15
|
const HELP = `
|
|
@@ -18,7 +19,9 @@ const HELP = `
|
|
|
18
19
|
text) and prints turns, channels, reactions, the hints sent, per agent the
|
|
19
20
|
turns, replies filed and hints sent, and for each
|
|
20
21
|
label (short, long, fast, skimming, friction) how the replies written while
|
|
21
|
-
it was active compare with the replies written while none was.
|
|
22
|
+
it was active compare with the replies written while none was. Last, the
|
|
23
|
+
replies that claimed a result (memory/signals/claim-checks/), and which of
|
|
24
|
+
them had no command behind the claim or were sent back.
|
|
22
25
|
|
|
23
26
|
Usage: pal cli interaction report [--days N] (default 7)
|
|
24
27
|
`;
|
|
@@ -39,8 +42,11 @@ export function run(argv: string[] = scriptArgs()) {
|
|
|
39
42
|
console.log(HELP);
|
|
40
43
|
return;
|
|
41
44
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
45
|
+
const since = new Date(Date.now() - days * DAY_MS);
|
|
46
|
+
const events = turnsSince(since);
|
|
47
|
+
const claims = claimCheckLines(claimChecksSince(since));
|
|
48
|
+
for (const line of [...reportLines(summarize(events), days), ...claims])
|
|
49
|
+
console.log(line);
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
if (import.meta.main) run();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* the agent's replies changed while a measured label was in its context.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { ClaimRecord } from "../../hooks/lib/claim-log";
|
|
6
7
|
import type { TurnEvent } from "../../hooks/lib/interaction";
|
|
7
8
|
import { median } from "../../hooks/lib/interaction-mood";
|
|
8
9
|
import {
|
|
@@ -198,6 +199,22 @@ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
|
|
|
198
199
|
return parts.join(" · ");
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
const UNBACKED_LISTED = 5;
|
|
203
|
+
|
|
204
|
+
export function claimCheckLines(records: ClaimRecord[]): string[] {
|
|
205
|
+
if (records.length === 0) return [];
|
|
206
|
+
const unbacked = records.filter((r) => r.verdict === "unbacked");
|
|
207
|
+
const unknown = records.filter((r) => r.verdict === "unknown").length;
|
|
208
|
+
const sentBack = records.filter((r) => r.blocked).length;
|
|
209
|
+
return [
|
|
210
|
+
"",
|
|
211
|
+
`Result claims: ${plural(records.length, "reply", "replies")} · ${unbacked.length} with no command behind it · ${sentBack} sent back · ${unknown} unreadable`,
|
|
212
|
+
...unbacked
|
|
213
|
+
.slice(-UNBACKED_LISTED)
|
|
214
|
+
.flatMap((r) => (r.claims ?? []).map((c) => ` no command: ${c}`)),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
201
218
|
function plural(n: number, noun: string, nouns = `${noun}s`): string {
|
|
202
219
|
return `${n} ${n === 1 ? noun : nouns}`;
|
|
203
220
|
}
|