portable-agent-layer 0.85.5 → 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.
@@ -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
  },
@@ -29,6 +29,8 @@
29
29
  "dueReminders": false,
30
30
  "interactionAwareness": true,
31
31
  "reactionSampling": true,
32
+ "claimCheck": true,
33
+ "claimCheckBlocks": false,
32
34
  "steeringTestReport": false
33
35
  },
34
36
  "steering": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.5",
3
+ "version": "0.85.7",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { checkReadmeSync } from "./handlers/readme-sync";
9
- import { blockResponse, isCodex, isCursor } from "./lib/agent";
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
- if (isCursor()) {
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
- await stopTurn(await readStdinJSON<StopTurnPayload>());
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);
@@ -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
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { median } from "./interaction-mood";
9
+ import { approvalIsKnown } from "./interaction-reaction";
9
10
 
10
11
  const MIN_REPLIES_PER_SIDE = 20;
11
12
  const MIN_GAP = 0.1;
@@ -20,6 +21,7 @@ interface ReplyFeatures {
20
21
  interface ReactedTurn {
21
22
  reply: ReplyFeatures | null;
22
23
  reaction: string | null;
24
+ reactionRules?: number;
23
25
  }
24
26
 
25
27
  interface Outcome {
@@ -41,7 +43,9 @@ export interface Split {
41
43
 
42
44
  function outcomes(events: ReactedTurn[]): Outcome[] {
43
45
  return events.flatMap((e) =>
44
- e.reply && e.reaction ? [{ reply: e.reply, approved: e.reaction === "approved" }] : []
46
+ e.reply && e.reaction && approvalIsKnown(e)
47
+ ? [{ reply: e.reply, approved: e.reaction === "approved" }]
48
+ : []
45
49
  );
46
50
  }
47
51
 
@@ -6,7 +6,21 @@
6
6
 
7
7
  import { extractKeywords, similarity } from "./text-similarity";
8
8
 
9
- export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
9
+ export type Reaction =
10
+ | "corrected"
11
+ | "repeated"
12
+ | "approved"
13
+ | "go-ahead"
14
+ | "new-topic"
15
+ | "follow-up";
16
+
17
+ /** Logged with each reaction, so a log read under older rules is not read with today's meaning. */
18
+ export const REACTION_RULES = 2;
19
+ const GO_AHEAD_SPLIT = 2;
20
+
21
+ export function approvalIsKnown(turn: { reactionRules?: number }): boolean {
22
+ return (turn.reactionRules ?? 1) >= GO_AHEAD_SPLIT;
23
+ }
10
24
 
11
25
  const CORRECTION_RE =
12
26
  /^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b|i don'?t see (?:the|your) (?!(?:problem|issue|point|harm|difference|need|reason)\b))/i;
@@ -15,7 +29,10 @@ const CLAIM_DISPUTED_RE =
15
29
  /\byou(?:'re| are) (?:partially |partly |completely |totally )?wrong\b|\b(?:isn'?t|wasn'?t|aren'?t|weren'?t) (?:added|included|fixed|pushed|committed|applied|merged)\b/i;
16
30
 
17
31
  const APPROVAL_RE =
18
- /^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that) (?:is|'s) (?:fine|good|great|right))\b/i;
32
+ /^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
33
+
34
+ const PRAISE_RE =
35
+ /\b(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|thanks|thank you|thx|exactly|correct|agreed|lgtm|well done|good job|love it|sounds good|looks (?:good|right)|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
19
36
 
20
37
  const GO_AHEAD_RE =
21
38
  /^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go|build|start|fix|ship)|(?:add|do|fix) (?:it )?(?:pls|please))\b/i;
@@ -51,7 +68,7 @@ function sentences(text: string): string[] {
51
68
  .filter(Boolean);
52
69
  }
53
70
 
54
- function isApproval(text: string): boolean {
71
+ function isAcceptance(text: string): boolean {
55
72
  const trimmed = text.trim();
56
73
  if (trimmed.includes("?") || FAILURE_RE.test(trimmed)) return false;
57
74
  if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
@@ -61,6 +78,11 @@ function isApproval(text: string): boolean {
61
78
  return isShortGoAhead(all[0] ?? "") && !HEDGE_RE.test(trimmed);
62
79
  }
63
80
 
81
+ /** A yes or a go only gives permission to continue; approval judges the work itself. */
82
+ function praisesTheResult(text: string): boolean {
83
+ return PRAISE_RE.test(text);
84
+ }
85
+
64
86
  /** Word overlap was measured against real messages and does not separate topics; only saying so does. */
65
87
  function isNewTopic(text: string): boolean {
66
88
  return NEW_TOPIC_RE.test(text.trim());
@@ -69,7 +91,7 @@ function isNewTopic(text: string): boolean {
69
91
  export function reactionTo(text: string, previousPrompt: string | undefined): Reaction {
70
92
  if (isCorrection(text)) return "corrected";
71
93
  if (isRepeat(text, previousPrompt)) return "repeated";
72
- if (isApproval(text)) return "approved";
94
+ if (isAcceptance(text)) return praisesTheResult(text) ? "approved" : "go-ahead";
73
95
  if (isNewTopic(text)) return "new-topic";
74
96
  return "follow-up";
75
97
  }
@@ -21,6 +21,7 @@ export interface ReactionSample {
21
21
  const KEPT: Record<Reaction, number> = {
22
22
  "follow-up": 150,
23
23
  approved: 20,
24
+ "go-ahead": 20,
24
25
  corrected: 20,
25
26
  repeated: 20,
26
27
  "new-topic": 20,
@@ -26,6 +26,7 @@ import { shorterApprovedMore } from "./interaction-preferences";
26
26
  import {
27
27
  isCorrection,
28
28
  isRepeat,
29
+ REACTION_RULES,
29
30
  type Reaction,
30
31
  reactionTo,
31
32
  } from "./interaction-reaction";
@@ -51,6 +52,7 @@ export interface TurnEvent extends MoodTurn {
51
52
  weekday: string;
52
53
  reply: Omit<ReplyShape, "at"> | null;
53
54
  reaction: Reaction | null;
55
+ reactionRules?: number;
54
56
  mood?: string;
55
57
  hinted?: boolean;
56
58
  complied?: boolean;
@@ -129,6 +131,7 @@ function measureTurn(
129
131
  corrected: isCorrection(text),
130
132
  reply: reply ? replyFeatures(reply) : null,
131
133
  reaction: reply ? reactionTo(text, track.lastPrompt) : null,
134
+ ...(reply && { reactionRules: REACTION_RULES }),
132
135
  };
133
136
  }
134
137
 
@@ -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 events = turnsSince(new Date(Date.now() - days * DAY_MS));
43
- for (const line of reportLines(summarize(events), days)) console.log(line);
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 {
@@ -14,13 +15,14 @@ import {
14
15
  type Split,
15
16
  splits,
16
17
  } from "../../hooks/lib/interaction-preferences";
18
+ import { approvalIsKnown } from "../../hooks/lib/interaction-reaction";
17
19
 
18
20
  const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
19
21
 
20
22
  interface ReplyUnderMood {
21
23
  labels: string[];
22
24
  words: number;
23
- approved: boolean;
25
+ approved: boolean | null;
24
26
  corrected: boolean;
25
27
  complied?: boolean;
26
28
  }
@@ -52,6 +54,7 @@ export interface InteractionSummary {
52
54
  reactions: Record<string, number>;
53
55
  hints: Record<string, number>;
54
56
  unlogged: number;
57
+ readBeforeGoAhead: number;
55
58
  usual: ReplyStats;
56
59
  byLabel: Record<string, ReplyStats>;
57
60
  approvals: Approvals;
@@ -81,7 +84,10 @@ function stats(replies: ReplyUnderMood[]): ReplyStats {
81
84
  return {
82
85
  replies: replies.length,
83
86
  medianWords: median(replies.map((r) => r.words)),
84
- approvedShare: share(replies, (r) => r.approved),
87
+ approvedShare: share(
88
+ replies.filter((r) => r.approved !== null),
89
+ (r) => r.approved === true
90
+ ),
85
91
  correctedShare: share(replies, (r) => r.corrected),
86
92
  followed: {
87
93
  checked: replies.filter((r) => r.complied !== undefined).length,
@@ -100,7 +106,7 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
100
106
  replies.push({
101
107
  labels: mood ? mood.split(",") : [],
102
108
  words: e.reply.words,
103
- approved: e.reaction === "approved",
109
+ approved: approvalIsKnown(e) ? e.reaction === "approved" : null,
104
110
  corrected: e.reaction === "corrected" || e.reaction === "repeated",
105
111
  complied: e.complied,
106
112
  });
@@ -109,6 +115,11 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
109
115
  return replies;
110
116
  }
111
117
 
118
+ function reactionName(e: TurnEvent): string {
119
+ if (e.reaction === "approved" && !approvalIsKnown(e)) return "approved or go-ahead";
120
+ return e.reaction ?? "";
121
+ }
122
+
112
123
  /** A turn carries the reply that answered the turn before it, so a filed reply shows up there. */
113
124
  function byAgent(events: TurnEvent[]): Record<string, AgentCounts> {
114
125
  const agents: Record<string, AgentCounts> = {};
@@ -131,7 +142,8 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
131
142
  sessions: new Set(events.map((e) => e.session)).size,
132
143
  channels: tally(events.map((e) => e.channel ?? "unknown")),
133
144
  agents: byAgent(events),
134
- reactions: tally(events.flatMap((e) => (e.reaction ? [e.reaction] : []))),
145
+ reactions: tally(events.flatMap((e) => (e.reaction ? [reactionName(e)] : []))),
146
+ readBeforeGoAhead: events.filter((e) => e.reaction && !approvalIsKnown(e)).length,
135
147
  hints: tally(hinted.map((e) => e.mood || "back to usual")),
136
148
  unlogged: events.filter((e) => e.mood === undefined).length,
137
149
  usual: stats(replies.filter((r) => r.labels.length === 0)),
@@ -187,6 +199,22 @@ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
187
199
  return parts.join(" · ");
188
200
  }
189
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
+
190
218
  function plural(n: number, noun: string, nouns = `${noun}s`): string {
191
219
  return `${n} ${n === 1 ? noun : nouns}`;
192
220
  }
@@ -255,5 +283,10 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
255
283
  "",
256
284
  `${summary.unlogged} older turns were logged before moods were, and are left out of the comparison.`
257
285
  );
286
+ if (summary.readBeforeGoAhead)
287
+ lines.push(
288
+ "",
289
+ `${summary.readBeforeGoAhead} older replies were read before go-ahead was split from approval, and are left out of the approval figures.`
290
+ );
258
291
  return lines;
259
292
  }