portable-agent-layer 0.80.0 → 0.81.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.
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/templates/PAL/MEMORY_SYSTEM.md +2 -1
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +1 -1
- package/src/hooks/lib/context.ts +3 -0
- package/src/hooks/lib/interaction-reaction.ts +71 -0
- package/src/hooks/lib/interaction-samples.ts +62 -0
- package/src/hooks/lib/interaction.ts +32 -12
- package/src/hooks/lib/prompt-text.ts +1 -0
- package/src/hooks/lib/reaction-audit.ts +115 -0
|
@@ -74,6 +74,10 @@
|
|
|
74
74
|
"type": "boolean",
|
|
75
75
|
"description": "Measure each user turn (pace, message length, skimming, corrections; never the text) into memory/signals/interaction/, and tell the agent when the session's pattern changes."
|
|
76
76
|
},
|
|
77
|
+
"reactionSampling": {
|
|
78
|
+
"type": "boolean",
|
|
79
|
+
"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."
|
|
80
|
+
},
|
|
77
81
|
"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." }
|
|
78
82
|
}
|
|
79
83
|
},
|
|
@@ -55,7 +55,8 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
|
|
|
55
55
|
| A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
|
|
56
56
|
| A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
|
|
57
57
|
| A rating signal | UserPromptOrchestrator rating capture | `signals/ratings.jsonl` |
|
|
58
|
-
| How a user turn went (pace, length, skimming,
|
|
58
|
+
| How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic) | `signals/interaction/YYYY-MM.jsonl` |
|
|
59
|
+
| A rolling sample of labelled messages, for auditing the reaction rules | Prompt-time interaction measurement (automatic, `reactionSampling`) | `state/reaction-samples.json` |
|
|
59
60
|
| A project handoff | Project skill / detached handlers | `projects/<slug>/` |
|
|
60
61
|
|
|
61
62
|
## Format conventions
|
package/package.json
CHANGED
package/src/hooks/lib/context.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { loadHandoffContext } from "./handoff-context";
|
|
|
14
14
|
import { loadOpinionContext } from "./opinions";
|
|
15
15
|
import { paths, toPath } from "./paths";
|
|
16
16
|
import { loadActiveProjectsContext } from "./projects";
|
|
17
|
+
import { loadReactionAuditNudge } from "./reaction-audit";
|
|
17
18
|
import { loadRecentNotes } from "./relationship";
|
|
18
19
|
import { loadFailurePatterns } from "./semi-static";
|
|
19
20
|
import { sessionDir } from "./session-dir";
|
|
@@ -220,12 +221,14 @@ export function buildSystemReminder(
|
|
|
220
221
|
: "";
|
|
221
222
|
// Maintainer-only: self-gates to a repo checkout, "" for everyone else.
|
|
222
223
|
const algoReview = oncePerDay("algorithm-review", loadAlgorithmReviewNudge());
|
|
224
|
+
const reactionAudit = oncePerDay("reaction-audit", loadReactionAuditNudge());
|
|
223
225
|
const reflectNudge = oncePerDay("reflect", loadReflectNudge());
|
|
224
226
|
const analyzeNudge = oncePerDay("analyze", loadAnalyzeNudge());
|
|
225
227
|
const parts: string[] = [];
|
|
226
228
|
if (startup) parts.push(startup);
|
|
227
229
|
if (handoff) parts.push(handoff);
|
|
228
230
|
if (algoReview) parts.push(algoReview);
|
|
231
|
+
if (reactionAudit) parts.push(reactionAudit);
|
|
229
232
|
if (reflectNudge) parts.push(reflectNudge);
|
|
230
233
|
if (analyzeNudge) parts.push(analyzeNudge);
|
|
231
234
|
if (selfModel) parts.push(selfModel);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the user's message answers the agent's previous reply, read in code.
|
|
3
|
+
* This is the outcome a reply is judged by, so precision beats recall:
|
|
4
|
+
* anything unclear is a follow-up, never a guess at approval or complaint.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { extractKeywords, similarity } from "./text-similarity";
|
|
8
|
+
|
|
9
|
+
export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
|
|
10
|
+
|
|
11
|
+
export interface ReplyContext {
|
|
12
|
+
previousPrompt?: string;
|
|
13
|
+
replyKeywords: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CORRECTION_RE =
|
|
17
|
+
/^(?:(?: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;
|
|
18
|
+
|
|
19
|
+
const APPROVAL_RE =
|
|
20
|
+
/^(?: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;
|
|
21
|
+
|
|
22
|
+
const GO_AHEAD_RE =
|
|
23
|
+
/^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|approved|let'?s (?:see|do|go))\b/i;
|
|
24
|
+
|
|
25
|
+
const GO_AHEAD_MAX_WORDS = 6;
|
|
26
|
+
const BARE_OK_RE = /^(?:ok(?:ay)?|k)[,.!]?(?:\s+\S+){0,2}$/i;
|
|
27
|
+
const HEDGE_RE = /\b(?:but|however|though|except|instead)\b/i;
|
|
28
|
+
const REPEAT_SIMILARITY = 0.6;
|
|
29
|
+
const REPEAT_MIN_KEYWORDS = 5;
|
|
30
|
+
const NEW_TOPIC_MIN_KEYWORDS = 4;
|
|
31
|
+
const NEW_TOPIC_MAX_OVERLAP = 0.15;
|
|
32
|
+
|
|
33
|
+
export function isCorrection(text: string): boolean {
|
|
34
|
+
return CORRECTION_RE.test(text.trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isRepeat(text: string, previous: string | undefined): boolean {
|
|
38
|
+
if (!previous || extractKeywords(text).size < REPEAT_MIN_KEYWORDS) return false;
|
|
39
|
+
return similarity(text, previous) >= REPEAT_SIMILARITY;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isApproval(text: string): boolean {
|
|
43
|
+
const trimmed = text.trim();
|
|
44
|
+
if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
|
|
45
|
+
if (APPROVAL_RE.test(trimmed) || BARE_OK_RE.test(trimmed)) return true;
|
|
46
|
+
return GO_AHEAD_RE.test(trimmed) && trimmed.split(/\s+/).length <= GO_AHEAD_MAX_WORDS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Few of the message's words appear in the exchange it follows. */
|
|
50
|
+
function isNewTopic(text: string, context: ReplyContext): boolean {
|
|
51
|
+
const words = extractKeywords(text);
|
|
52
|
+
if (words.size < NEW_TOPIC_MIN_KEYWORDS) return false;
|
|
53
|
+
const exchange = new Set([
|
|
54
|
+
...context.replyKeywords,
|
|
55
|
+
...extractKeywords(context.previousPrompt ?? ""),
|
|
56
|
+
]);
|
|
57
|
+
const shared = [...words].filter((w) => exchange.has(w)).length;
|
|
58
|
+
return shared / words.size <= NEW_TOPIC_MAX_OVERLAP;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function reactionTo(text: string, context: ReplyContext): Reaction {
|
|
62
|
+
if (isCorrection(text)) return "corrected";
|
|
63
|
+
if (isRepeat(text, context.previousPrompt)) return "repeated";
|
|
64
|
+
if (isApproval(text)) return "approved";
|
|
65
|
+
if (isNewTopic(text, context)) return "new-topic";
|
|
66
|
+
return "follow-up";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function replyKeywords(reply: string): string[] {
|
|
70
|
+
return [...extractKeywords(reply)];
|
|
71
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rolling, local sample of labelled messages with the reply they answered,
|
|
3
|
+
* kept so the reaction rules can be audited against the user's real language.
|
|
4
|
+
* Follow-ups get most of the room: that is where the rules' misses land.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type { Reaction } from "./interaction-reaction";
|
|
10
|
+
import { paths } from "./paths";
|
|
11
|
+
import { isEnabled } from "./settings";
|
|
12
|
+
|
|
13
|
+
export interface ReactionSample {
|
|
14
|
+
ts: string;
|
|
15
|
+
session: string;
|
|
16
|
+
reaction: Reaction;
|
|
17
|
+
text: string;
|
|
18
|
+
replyEnd: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEPT: Record<Reaction, number> = {
|
|
22
|
+
"follow-up": 150,
|
|
23
|
+
approved: 20,
|
|
24
|
+
corrected: 20,
|
|
25
|
+
repeated: 20,
|
|
26
|
+
"new-topic": 20,
|
|
27
|
+
};
|
|
28
|
+
const TEXT_MAX = 1000;
|
|
29
|
+
const REPLY_END_MAX = 600;
|
|
30
|
+
|
|
31
|
+
function samplesFile(): string {
|
|
32
|
+
return resolve(paths.state(), "reaction-samples.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @lintignore exercised directly by test/interaction-samples.test.ts */
|
|
36
|
+
export function readSamples(): ReactionSample[] {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(samplesFile(), "utf-8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A rare label keeps its own room, so frequent approvals never push out the corrections. */
|
|
45
|
+
function newestPerReaction(samples: ReactionSample[]): ReactionSample[] {
|
|
46
|
+
const kept = (Object.keys(KEPT) as Reaction[]).flatMap((reaction) =>
|
|
47
|
+
samples.filter((s) => s.reaction === reaction).slice(-KEPT[reaction])
|
|
48
|
+
);
|
|
49
|
+
return kept.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The end of a reply is what the user's next message usually answers. */
|
|
53
|
+
export function replyEnd(reply: string): string {
|
|
54
|
+
return reply.trim().slice(-REPLY_END_MAX);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function keepSample(sample: ReactionSample): void {
|
|
58
|
+
if (!isEnabled("reactionSampling")) return;
|
|
59
|
+
const trimmed = { ...sample, text: sample.text.slice(0, TEXT_MAX) };
|
|
60
|
+
const samples = newestPerReaction([...readSamples(), trimmed]);
|
|
61
|
+
writeFileSync(samplesFile(), JSON.stringify(samples), "utf-8");
|
|
62
|
+
}
|
|
@@ -14,10 +14,17 @@ import {
|
|
|
14
14
|
moodReminder,
|
|
15
15
|
readMood,
|
|
16
16
|
} from "./interaction-mood";
|
|
17
|
+
import {
|
|
18
|
+
isCorrection,
|
|
19
|
+
isRepeat,
|
|
20
|
+
type Reaction,
|
|
21
|
+
reactionTo,
|
|
22
|
+
replyKeywords,
|
|
23
|
+
} from "./interaction-reaction";
|
|
24
|
+
import { keepSample, replyEnd } from "./interaction-samples";
|
|
17
25
|
import { ensureDir, paths } from "./paths";
|
|
18
26
|
import { isSystemText, stripInjectedTags } from "./prompt-text";
|
|
19
27
|
import { isEnabled } from "./settings";
|
|
20
|
-
import { extractKeywords, similarity } from "./text-similarity";
|
|
21
28
|
import { localClock } from "./wall-clock";
|
|
22
29
|
|
|
23
30
|
interface ReplyShape {
|
|
@@ -35,6 +42,7 @@ export interface TurnEvent extends MoodTurn {
|
|
|
35
42
|
hour: number;
|
|
36
43
|
weekday: string;
|
|
37
44
|
reply: Omit<ReplyShape, "at"> | null;
|
|
45
|
+
reaction: Reaction | null;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
interface SessionTrack {
|
|
@@ -42,6 +50,8 @@ interface SessionTrack {
|
|
|
42
50
|
lastPrompt?: string;
|
|
43
51
|
lastPromptAt?: string;
|
|
44
52
|
reply?: ReplyShape;
|
|
53
|
+
replyKeywords?: string[];
|
|
54
|
+
replyEnd?: string;
|
|
45
55
|
recent: TurnEvent[];
|
|
46
56
|
mood?: string;
|
|
47
57
|
}
|
|
@@ -49,15 +59,10 @@ interface SessionTrack {
|
|
|
49
59
|
const BREAK_SEC = 20 * 60;
|
|
50
60
|
const SKIM_MIN_WORDS = 150;
|
|
51
61
|
const SKIM_WORDS_PER_SEC = 10;
|
|
52
|
-
const REPEAT_SIMILARITY = 0.6;
|
|
53
|
-
const REPEAT_MIN_KEYWORDS = 5;
|
|
54
62
|
const RECENT_KEPT = 12;
|
|
55
63
|
const MAX_TRACKED_SESSIONS = 20;
|
|
56
64
|
const BASELINE_MIN_SAMPLES = 20;
|
|
57
65
|
|
|
58
|
-
const CORRECTION_RE =
|
|
59
|
-
/^(?:(?:no|nope)(?:[,.!]|$)|wrong\b|that'?s 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;
|
|
60
|
-
|
|
61
66
|
function wordCount(text: string): number {
|
|
62
67
|
return text.split(/\s+/).filter(Boolean).length;
|
|
63
68
|
}
|
|
@@ -73,11 +78,6 @@ function replyShape(text: string, at: Date): ReplyShape {
|
|
|
73
78
|
};
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
function isRepeat(text: string, previous: string | undefined): boolean {
|
|
77
|
-
if (!previous || extractKeywords(text).size < REPEAT_MIN_KEYWORDS) return false;
|
|
78
|
-
return similarity(text, previous) >= REPEAT_SIMILARITY;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
81
|
/** The reply belongs to this turn only if it arrived after the previous prompt. */
|
|
82
82
|
function answeredReply(track: SessionTrack): ReplyShape | null {
|
|
83
83
|
const reply = track.reply;
|
|
@@ -115,8 +115,14 @@ function measureTurn(
|
|
|
115
115
|
skimmed: isSkimmed(reply, gapSec),
|
|
116
116
|
interrupted: Boolean(track.lastPromptAt) && !reply,
|
|
117
117
|
repeated: isRepeat(text, track.lastPrompt),
|
|
118
|
-
corrected:
|
|
118
|
+
corrected: isCorrection(text),
|
|
119
119
|
reply: reply ? replyFeatures(reply) : null,
|
|
120
|
+
reaction: reply
|
|
121
|
+
? reactionTo(text, {
|
|
122
|
+
previousPrompt: track.lastPrompt,
|
|
123
|
+
replyKeywords: track.replyKeywords ?? [],
|
|
124
|
+
})
|
|
125
|
+
: null,
|
|
120
126
|
};
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -205,6 +211,19 @@ export function recordReply(
|
|
|
205
211
|
...track,
|
|
206
212
|
updated: now.toISOString(),
|
|
207
213
|
reply: replyShape(reply, now),
|
|
214
|
+
replyKeywords: replyKeywords(reply),
|
|
215
|
+
replyEnd: replyEnd(reply),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sampleReaction(event: TurnEvent, text: string, track: SessionTrack): void {
|
|
220
|
+
if (!event.reaction) return;
|
|
221
|
+
keepSample({
|
|
222
|
+
ts: event.ts,
|
|
223
|
+
session: event.session,
|
|
224
|
+
reaction: event.reaction,
|
|
225
|
+
text,
|
|
226
|
+
replyEnd: track.replyEnd ?? "",
|
|
208
227
|
});
|
|
209
228
|
}
|
|
210
229
|
|
|
@@ -221,6 +240,7 @@ export function observeTurn(
|
|
|
221
240
|
const track = trackOf(session);
|
|
222
241
|
const event = measureTurn(text, session, track, now, channel);
|
|
223
242
|
appendEvent(event);
|
|
243
|
+
sampleReaction(event, text, track);
|
|
224
244
|
const recent = [...track.recent, event].slice(-RECENT_KEPT);
|
|
225
245
|
const mood = readMood(recent, baseline(session, now));
|
|
226
246
|
writeTrack(session, {
|
|
@@ -16,6 +16,7 @@ const SYSTEM_TEXT_PATTERNS = [
|
|
|
16
16
|
/^This session is being continued from a previous conversation/i,
|
|
17
17
|
/^Please continue the conversation/i,
|
|
18
18
|
/^Note:.*was read before/i,
|
|
19
|
+
/^Another Claude session sent a message:/i,
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
export function isSystemText(prompt: string): boolean {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaction-rule audit cadence and comparison. Maintainer-only: the nudge fires
|
|
3
|
+
* solely in a PAL checkout (see algorithm-review.ts), and the audit never edits
|
|
4
|
+
* the rules, it only reports where a model's reading disagrees with them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { isMaintainerEnv } from "./algorithm-review";
|
|
10
|
+
import type { Reaction } from "./interaction-reaction";
|
|
11
|
+
import { type ReactionSample, readSamples } from "./interaction-samples";
|
|
12
|
+
import { paths } from "./paths";
|
|
13
|
+
|
|
14
|
+
interface AuditMark {
|
|
15
|
+
lastAuditTs: string;
|
|
16
|
+
followUpShare: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BlindSample {
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
replyEnd: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Disagreement extends BlindSample {
|
|
26
|
+
rule: Reaction;
|
|
27
|
+
model: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AuditComparison {
|
|
31
|
+
compared: number;
|
|
32
|
+
agreed: number;
|
|
33
|
+
disagreements: Disagreement[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NUDGE_MIN_NEW = 40;
|
|
37
|
+
const DRIFT_MIN_NEW = 20;
|
|
38
|
+
const DRIFT_MIN_DELTA = 0.15;
|
|
39
|
+
|
|
40
|
+
function markFile(): string {
|
|
41
|
+
return resolve(paths.state(), "reaction-audit.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readAuditMark(): AuditMark | null {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(markFile(), "utf-8"));
|
|
47
|
+
return typeof parsed.lastAuditTs === "string" ? parsed : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function followUpShare(samples: ReactionSample[]): number {
|
|
54
|
+
if (samples.length === 0) return 0;
|
|
55
|
+
return samples.filter((s) => s.reaction === "follow-up").length / samples.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function unauditedSamples(mark = readAuditMark()): ReactionSample[] {
|
|
59
|
+
const samples = readSamples();
|
|
60
|
+
return mark ? samples.filter((s) => s.ts > mark.lastAuditTs) : samples;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The rule's label stays out, so the model's reading is a second opinion, not a review. */
|
|
64
|
+
export function blindSamples(samples: ReactionSample[]): BlindSample[] {
|
|
65
|
+
return samples.map(({ ts, text, replyEnd }) => ({ id: ts, text, replyEnd }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function compareLabels(
|
|
69
|
+
samples: ReactionSample[],
|
|
70
|
+
modelLabels: Record<string, string>
|
|
71
|
+
): AuditComparison {
|
|
72
|
+
const labelled = samples.filter((s) => modelLabels[s.ts]);
|
|
73
|
+
const disagreements = labelled
|
|
74
|
+
.filter((s) => modelLabels[s.ts] !== s.reaction)
|
|
75
|
+
.map((s) => ({
|
|
76
|
+
...blindSamples([s])[0],
|
|
77
|
+
rule: s.reaction,
|
|
78
|
+
model: modelLabels[s.ts],
|
|
79
|
+
}));
|
|
80
|
+
return {
|
|
81
|
+
compared: labelled.length,
|
|
82
|
+
agreed: labelled.length - disagreements.length,
|
|
83
|
+
disagreements,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeAuditMark(now: Date = new Date()): void {
|
|
88
|
+
const mark: AuditMark = {
|
|
89
|
+
lastAuditTs: now.toISOString(),
|
|
90
|
+
followUpShare: followUpShare(readSamples()),
|
|
91
|
+
};
|
|
92
|
+
writeFileSync(markFile(), `${JSON.stringify(mark, null, 2)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function auditReason(): string | null {
|
|
96
|
+
const mark = readAuditMark();
|
|
97
|
+
const fresh = unauditedSamples(mark);
|
|
98
|
+
if (fresh.length >= NUDGE_MIN_NEW) return `${fresh.length} new labelled messages`;
|
|
99
|
+
if (!mark || fresh.length < DRIFT_MIN_NEW) return null;
|
|
100
|
+
const share = followUpShare(fresh);
|
|
101
|
+
if (Math.abs(share - mark.followUpShare) < DRIFT_MIN_DELTA) return null;
|
|
102
|
+
const pct = (n: number) => `${Math.round(n * 100)}%`;
|
|
103
|
+
return `the follow-up share moved from ${pct(mark.followUpShare)} to ${pct(share)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Formatted nudge section for the session reminder, or "" when it shouldn't fire. */
|
|
107
|
+
export function loadReactionAuditNudge(cwd: string = process.cwd()): string {
|
|
108
|
+
if (!isMaintainerEnv(cwd)) return "";
|
|
109
|
+
const reason = auditReason();
|
|
110
|
+
if (!reason) return "";
|
|
111
|
+
return [
|
|
112
|
+
"## Reaction Rules Audit Due",
|
|
113
|
+
`🔎 ${reason} since the last audit — run \`/reaction-audit\` to check the reaction rules against them.`,
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|