1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
//! The prompt-feedback ledger: extract, append, read back (#7688).
//!
//! Why: the addendum
//! [`prompt_self_improvement`](crate::core::prompt_self_improvement) asks for is
//! worth nothing if it dies in a transcript nobody reads. This module is the
//! durable side — one append-only JSON-Lines file that a `Stop`/`SubagentStop`
//! hook writes and `tm prompt-feedback` reads. JSON Lines rather than a
//! database for the reason the savings ledger chose it: an append is one
//! `write`, concurrent writers from several sessions interleave whole lines
//! rather than corrupting a shared structure, and a malformed line costs that
//! line alone.
//!
//! What: [`extract_feedback`] pulls the `## Prompt feedback` section out of a
//! final assistant message; [`append_row`] writes one [`FeedbackRow`];
//! [`read_rows`] reads them newest-first with the filters the CLI exposes; and
//! [`summarize`] groups them by agent type.
//!
//! 🔴 EVERY WRITE PATH HERE FAILS OPEN. A hook that cannot read a transcript or
//! cannot write this ledger logs a `warn` and returns `Ok`-shaped nothing —
//! never an error that could reach Claude Code's hook exit status. A prompt
//! critique is not worth blocking a session over, and a non-zero `Stop` hook is
//! visible to the operator as a broken session. The arm is pinned by
//! `append_row_on_an_unwritable_ledger_is_not_an_error`.
//!
//! Test: `prompt_feedback_tests.rs`.
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::prompt_self_improvement::FEEDBACK_HEADING;
/// Basename of the ledger under the framework root.
///
/// What: `prompt-feedback.jsonl`, i.e. `~/.trusty-mpm/prompt-feedback.jsonl`.
/// Test: `ledger_path_is_a_root_level_jsonl_file`.
pub const LEDGER_FILE: &str = "prompt-feedback.jsonl";
/// The `agent_type` recorded for the PM's own `Stop` event.
///
/// Why: a `Stop` carries no `agent_type` — it is the main session ending — so
/// the column would be null for exactly the rows an operator most wants to
/// group. A fixed sentinel keeps [`summarize`] a single grouping over one
/// non-null column.
///
/// 🔴 A `Stop`, and ONLY a `Stop`. An absent `agent_type` on a `SubagentStop`
/// is not evidence that the PM spoke — see [`UNKNOWN_SUBAGENT_TYPE`].
/// What: `"pm"`.
/// Test: `a_stop_payload_writes_a_pm_row`.
pub const PM_AGENT_TYPE: &str = "pm";
/// The `agent_type` recorded for a subagent stop that named no type (#7702).
///
/// Why: `SubagentStop` normally carries `agent_type`, but the daemon's own
/// delegation tracker already treats a stop without one as evidence about
/// nothing rather than as the PM's
/// (`a_stop_without_an_agent_type_reconciles_nothing`). Folding such a row into
/// [`PM_AGENT_TYPE`] would book a subagent's critique against the PM and
/// corrupt [`summarize`], whose entire job is naming which agent type
/// complains most. A distinct sentinel keeps the count honest and stays
/// visible in the output as a thing to investigate.
/// What: `"unknown-subagent"`.
/// Test: `a_subagent_stop_without_an_agent_type_is_not_the_pm`.
pub const UNKNOWN_SUBAGENT_TYPE: &str = "unknown-subagent";
/// How many bytes of one feedback section are stored.
///
/// Why: the addendum is specified as at most five lines, but nothing enforces
/// that on the model, and an unbounded capture would let one runaway response
/// write a megabyte into a file every later read must stream. The cap is far
/// above any honest five lines, so a well-formed row is never touched.
/// What: 4 KiB, applied on a char boundary so the stored text stays valid UTF-8.
/// Test: `an_oversized_feedback_section_is_truncated`.
const MAX_FEEDBACK_BYTES: usize = 4096;
/// One captured prompt critique.
///
/// Why: the row is the whole product of this feature, so its shape is the
/// contract the follow-up optimization pass reads. Every field is either
/// present or explicitly `null`; none is inferred later.
/// What: `ts` is RFC 3339 UTC; `session_id` is Claude Code's own; `agent_type`
/// is the subagent's type or [`PM_AGENT_TYPE`]; `prompt_digest` is a sha256 of
/// the composed prompt when one could be read, else `null`; `feedback` is the
/// extracted section body.
/// Test: `append_then_read_round_trips_a_row`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeedbackRow {
/// When the hook fired, RFC 3339 UTC.
pub ts: String,
/// Claude Code's session id, or `null` when the payload carried none.
#[serde(default)]
pub session_id: Option<String>,
/// The subagent's type, [`PM_AGENT_TYPE`] for the session itself, or
/// [`UNKNOWN_SUBAGENT_TYPE`] for a subagent stop that named none.
pub agent_type: String,
/// sha256 of the composed prompt this feedback is about, when known.
#[serde(default)]
pub prompt_digest: Option<String>,
/// The extracted `## Prompt feedback` body.
pub feedback: String,
}
/// Path of the ledger under `framework_root`.
///
/// Why: taken as an argument rather than resolved from `$HOME` so a test names
/// a tempdir instead of writing the operator's real ledger — and without the
/// PROCESS-GLOBAL `$HOME` write #5544 forbids in this binary.
/// What: `<framework_root>/prompt-feedback.jsonl`.
/// Test: `ledger_path_is_a_root_level_jsonl_file`.
pub fn ledger_path(framework_root: &Path) -> PathBuf {
framework_root.join(LEDGER_FILE)
}
/// Pull the `## Prompt feedback` section out of a final assistant message.
///
/// Why: the model is asked to end its response with this section, but it ends a
/// response that also contains the actual work — so the extractor must find the
/// heading rather than assume the whole message is feedback. It stops at the
/// next same-or-higher-level Markdown heading, because the addendum is
/// specified as the LAST thing in a response and anything after a following
/// `##` belongs to something else.
///
/// What: `Some(body)` for the LAST occurrence of [`FEEDBACK_HEADING`] at the
/// start of a line, trimmed, and truncated to [`MAX_FEEDBACK_BYTES`] on a char
/// boundary. `None` when the heading is absent or its body is empty — a
/// response that did not answer is not a row.
///
/// The LAST occurrence, not the first: a response that quotes the instruction
/// ("end with a `## Prompt feedback` addendum") before complying would
/// otherwise capture the quote and stop at the real section's heading.
/// Test: `extracts_the_section_body`, `extracts_the_last_section`,
/// `stops_at_the_next_heading`, `absent_heading_extracts_nothing`,
/// `an_empty_section_extracts_nothing`,
/// `an_oversized_feedback_section_is_truncated`.
pub fn extract_feedback(message: &str) -> Option<String> {
let start = last_heading_offset(message)?;
let after = &message[start + FEEDBACK_HEADING.len()..];
let mut body = String::new();
for line in after.lines().skip_while(|l| l.trim().is_empty()) {
// A same-or-higher-level heading ends the section. `###` is deeper, so
// it is part of the feedback and does not terminate it.
let trimmed = line.trim_start();
if (trimmed.starts_with("## ") && !trimmed.starts_with("### ")) || trimmed.starts_with("# ")
{
break;
}
body.push_str(line);
body.push('\n');
}
let body = body.trim();
if body.is_empty() {
return None;
}
Some(truncate_on_char_boundary(body, MAX_FEEDBACK_BYTES).to_string())
}
/// Byte offset of the last line-initial [`FEEDBACK_HEADING`] in `message`.
///
/// What: `None` when the heading never starts a line. Requires the heading to
/// begin a line so a mention inside a sentence is not mistaken for the section.
/// Test: via [`extract_feedback`]'s tests.
fn last_heading_offset(message: &str) -> Option<usize> {
let mut found = None;
let mut offset = 0;
for line in message.split_inclusive('\n') {
let indent = line.len() - line.trim_start().len();
// The line must BE the heading — not merely contain it — so a sentence
// quoting the instruction never opens a section.
if line.trim() == FEEDBACK_HEADING {
found = Some(offset + indent);
}
offset += line.len();
}
found
}
/// Truncate `s` to at most `max` bytes without splitting a character.
///
/// Test: `an_oversized_feedback_section_is_truncated`.
fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Append one row to the ledger under `framework_root`.
///
/// Why: the hook's only write. It is `O_APPEND` on one open file handle, which
/// on every platform tm supports makes a single `write` of a line smaller than
/// the pipe buffer atomic against other appenders — so two sessions ending at
/// once interleave whole lines rather than shredding each other's.
///
/// 🔴 FAILS OPEN. Returns `false` and logs a `warn` when the directory cannot
/// be created, the file cannot be opened, or the write fails. It NEVER returns
/// an error, because its one caller is a Claude Code hook whose non-zero exit
/// the operator sees as a broken session.
/// What: serialises `row` as one JSON line. Returns whether it landed.
/// Test: `append_then_read_round_trips_a_row`,
/// `append_row_on_an_unwritable_ledger_is_not_an_error`.
pub fn append_row(framework_root: &Path, row: &FeedbackRow) -> bool {
let Ok(line) = serde_json::to_string(row) else {
tracing::warn!("prompt-feedback row could not be serialised; dropping it");
return false;
};
if let Err(err) = std::fs::create_dir_all(framework_root) {
tracing::warn!(
"prompt-feedback ledger directory {} unavailable: {err}; dropping the row",
framework_root.display()
);
return false;
}
let path = ledger_path(framework_root);
let opened = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path);
match opened {
Ok(mut file) => match writeln!(file, "{line}") {
Ok(()) => true,
Err(err) => {
tracing::warn!(
"prompt-feedback ledger {} could not be appended to: {err}; dropping the row",
path.display()
);
false
}
},
Err(err) => {
tracing::warn!(
"prompt-feedback ledger {} could not be opened: {err}; dropping the row",
path.display()
);
false
}
}
}
/// Filters `tm prompt-feedback` applies to a ledger read.
///
/// What: every field is optional and ANDed; an all-`None` filter matches every
/// row. `limit` caps the NEWEST rows returned, after the other filters.
/// Test: `read_rows_filters_by_session`, `read_rows_filters_by_agent`,
/// `read_rows_applies_the_limit_after_filtering`.
#[derive(Debug, Clone, Default)]
pub struct ReadFilter {
/// Keep only rows from this session id, or from one linked to it — see
/// `session_and_siblings` (private, so a plain span rather than a link).
pub session: Option<String>,
/// Keep only rows from this agent type.
pub agent: Option<String>,
/// Keep at most this many of the newest matching rows.
pub limit: Option<usize>,
}
/// Read the ledger newest-first, applying `filter`.
///
/// Why: newest-first because the question an operator asks of this file is
/// always "what did the last few runs say", never "what did it say in March".
/// What: parses every line, SKIPPING one that does not deserialise with a
/// `warn` — a partial line from a crashed writer must not hide the rest of the
/// file. An absent ledger reads as an empty list, not an error: nothing has
/// been captured yet is a normal state.
///
/// `--session` folds linked ids (#7702): see `session_and_siblings` below.
/// Test: `append_then_read_round_trips_a_row`, `read_rows_is_newest_first`,
/// `read_rows_skips_a_malformed_line`, `read_of_an_absent_ledger_is_empty`,
/// `read_rows_folds_a_linked_sibling_session`.
pub fn read_rows(framework_root: &Path, filter: &ReadFilter) -> Vec<FeedbackRow> {
let path = ledger_path(framework_root);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
tracing::warn!(
"prompt-feedback ledger {} could not be read: {err}",
path.display()
);
return Vec::new();
}
};
// Resolved once, before the scan: the link store is a directory read, and
// doing it per row would turn one small scan into one per line.
let sessions = filter
.session
.as_deref()
.map(|want| session_and_siblings(framework_root, want));
let mut rows: Vec<FeedbackRow> = raw
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| match serde_json::from_str::<FeedbackRow>(line) {
Ok(row) => Some(row),
Err(err) => {
tracing::warn!("skipping a malformed prompt-feedback line: {err}");
None
}
})
.filter(|row| {
sessions.as_ref().is_none_or(|want| {
row.session_id
.as_deref()
.is_some_and(|id| want.iter().any(|w| w == id))
})
})
.filter(|row| {
filter
.agent
.as_ref()
.is_none_or(|want| &row.agent_type == want)
})
.collect();
rows.reverse();
if let Some(limit) = filter.limit {
rows.truncate(limit);
}
rows
}
/// Every session id `--session <id>` accepts: `session` and its linked siblings.
///
/// Why (#7702, the #7617 lesson): Claude Code mints a NEW `session_id` on every
/// restart — a relaunch, a `/login` switch, a crash — so an operator who reads
/// back the id their session is running under today gets an EMPTY result for
/// feedback the same managed session captured an hour ago, with nothing saying
/// rows were excluded. That is exactly how the `💸` segment disappeared in
/// #7617, and the fix is the same fix: fold across the managed session's ids.
///
/// ONE IMPLEMENTATION. The sibling lookup is
/// [`linked_claude_ids`](crate::core::session_links::linked_claude_ids), the
/// same call the statusline fold makes; nothing here re-derives a link. Where
/// the statusline takes the FIRST sibling with rows — it renders one number —
/// this returns the union, because a listing shows every matching row.
/// What: the ids linked to `session`, which already include `session` itself;
/// `[session]` alone when nothing links it, which is every unmanaged session
/// and every read failure. Never empty, so an unlinked id still matches its own
/// rows.
/// Test: `read_rows_folds_a_linked_sibling_session`,
/// `read_rows_filters_by_session`.
fn session_and_siblings(framework_root: &Path, session: &str) -> Vec<String> {
let mut ids = crate::core::session_links::linked_claude_ids(framework_root, session);
if !ids.iter().any(|id| id == session) {
ids.push(session.to_string());
}
ids
}
/// Row counts per agent type, most frequent first.
///
/// Why: the `--summary` read. "Which agent type produces the most prompt
/// complaints" is the one question that points at which prompt to fix next.
/// What: `(agent_type, count)` descending by count, then by name so the output
/// is stable for equal counts.
/// Test: `summarize_counts_by_agent_type`, `summarize_orders_by_count_then_name`.
pub fn summarize(rows: &[FeedbackRow]) -> Vec<(String, usize)> {
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for row in rows {
*counts.entry(row.agent_type.clone()).or_insert(0) += 1;
}
let mut out: Vec<(String, usize)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
out
}
#[cfg(test)]
#[path = "prompt_feedback_tests.rs"]
mod tests;