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
//! A session: the events of one sitting, gathered under one roof.
//!
//! Until now an event knew its project and its day and nothing else, so
//! "what did we do last time" could only be answered by a window of days -
//! which is wrong in both directions. Two sessions in an evening became one
//! blur; a session spanning midnight became two.
//!
//! A session fixes the boundary by recording it. Everything written while
//! one is open belongs to it, `end` says what that was, and the next
//! packet can open with the difference rather than with a fixed number of
//! recent events.
//!
//! The end-of-session ritual is the thing this replaces. It has always been
//! a list in a skill file that the assistant had to remember at exactly the
//! moment it was running out of context - which is when it is least likely
//! to remember anything. A ritual that depends on remembering is a ritual
//! that stops happening.
use serde::Serialize;
use crate::db::{RecentEvent, Session};
/// What one session turned out to hold.
#[derive(Debug, Clone, Serialize)]
pub struct Summary {
pub project: String,
pub session: i64,
pub started_at: String,
pub ended_at: String,
pub decisions: Vec<String>,
pub findings: Vec<String>,
pub pitfalls: Vec<String>,
pub changes: Vec<String>,
/// Questions raised for the owner during the session.
pub questions: Vec<String>,
/// The line the next session starts from, if one was set.
pub next_step: Option<String>,
/// Versions whose tags landed inside the session.
pub shipped: Vec<String>,
/// Tasks closed while it was open.
pub tasks_closed: Vec<String>,
/// Commits made while it was open, by their subject line.
///
/// Not counted as recorded - git wrote them, not the session - but they
/// are what the session did, and the diary's "done" is the one place
/// where the work matters more than who wrote it down.
pub commits: Vec<String>,
}
impl Summary {
/// Whether anything at all was recorded.
///
/// A session that recorded nothing is not a failure - some sittings are
/// reading - but it is the one case where `end` has nothing to say and
/// should say so in a line rather than print empty headings.
pub fn empty(&self) -> bool {
self.decisions.is_empty()
&& self.findings.is_empty()
&& self.pitfalls.is_empty()
&& self.changes.is_empty()
&& self.questions.is_empty()
&& self.shipped.is_empty()
&& self.tasks_closed.is_empty()
&& self.commits.is_empty()
}
/// What the session did, for the diary's "done": the tasks it closed,
/// the changes it recorded, and the commits it made - each said once.
///
/// The three overlap: a change recorded by hand often repeats the commit
/// it describes. A line already said, by any of them, is not said again.
pub fn done(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in self.tasks_closed.iter().chain(&self.changes).chain(&self.commits) {
let line = line.trim();
if !line.is_empty() && !out.iter().any(|seen| seen.eq_ignore_ascii_case(line)) {
out.push(line.to_string());
}
}
out
}
pub fn recorded(&self) -> usize {
self.decisions.len() + self.findings.len() + self.pitfalls.len() + self.changes.len() + self.questions.len()
}
/// What the ritual asks the assistant to check before it stops.
///
/// Not a scolding and not a gate: `end` closes the session either way.
/// These are the two things the written ritual asks for by name and the
/// two most often missing, because they are the last steps of a session
/// and a session ends when attention has run out.
pub fn missing(&self) -> Vec<String> {
let mut out = Vec::new();
if self.next_step.is_none() {
out.push("no next step - the next session will open without a first line".to_string());
}
// Work with nothing written down about why is work the record cannot
// explain later. A session that shipped or changed something and
// recorded no reasoning is the shape this is looking for.
if !self.changes.is_empty() && self.decisions.is_empty() && self.findings.is_empty() && self.pitfalls.is_empty() {
out.push("changes but no decision, finding or pitfall - nothing says why".to_string());
}
out
}
}
/// Sorts a session's events into the summary's lists.
pub fn summarise(
project: &str,
session: &Session,
events: &[RecentEvent],
shipped: Vec<String>,
tasks_closed: Vec<String>,
commits: Vec<String>,
next_step: Option<String>,
) -> Summary {
let mut summary = Summary {
project: project.to_string(),
session: session.id,
started_at: session.started_at.clone(),
ended_at: session.ended_at.clone().unwrap_or_default(),
decisions: Vec::new(),
findings: Vec::new(),
pitfalls: Vec::new(),
changes: Vec::new(),
questions: Vec::new(),
next_step,
shipped,
tasks_closed,
commits,
};
for event in events {
// The chronicle read out of commit messages is not what a session
// did - it is what git already says, and `sync` writes it whenever
// it runs. Counting it here would make every session look busy.
if event.from_git {
continue;
}
let into = match event.kind.as_str() {
"decision" => &mut summary.decisions,
"finding" => &mut summary.findings,
"pitfall" => &mut summary.pitfalls,
"change" => &mut summary.changes,
"question" => &mut summary.questions,
_ => continue,
};
into.push(event.body.clone());
}
summary
}
/// The diary entry a session leaves behind.
///
/// Composed from what the session recorded, not written for it. rigger has
/// no opinion about the day and inventing one would put words in the
/// owner's diary that nobody said - so the entry is the session's own
/// sentences, arranged, with the headings the hub already uses.
///
/// It is a draft as much as an entry: `session draft` prints it before the
/// sitting closes, the assistant edits it rather than writing one from a
/// blank page, and `session end --entry` puts the edited text in its place.
pub fn diary_entry(summary: &Summary, day: &str, heading: Option<&str>) -> String {
let title = match heading {
Some(text) if !text.trim().is_empty() => format!("## {day} · {}", text.trim()),
_ => format!("## {day}"),
};
let mut out = format!("{title}\n");
let section = |out: &mut String, label: &str, items: &[String]| {
if items.is_empty() {
return;
}
out.push_str(&format!("\n**{label}**\n"));
for item in items {
// One bullet per event, kept whole: the entry is a record, and
// a summary of a summary loses the thing worth keeping.
out.push_str(&format!("\n- {}\n", item.trim()));
}
};
if !summary.shipped.is_empty() {
out.push_str(&format!("\n**Выпущено.** {}\n", summary.shipped.join(", ")));
}
section(&mut out, "Сделано.", &summary.done());
section(&mut out, "Решения.", &summary.decisions);
section(&mut out, "Находки.", &summary.findings);
section(&mut out, "Грабли.", &summary.pitfalls);
section(&mut out, "Ждёт владельца.", &summary.questions);
if let Some(next) = &summary.next_step {
out.push_str(&format!("\n**Следующий шаг.** {}\n", next.trim()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Session {
Session {
id: 7,
project_id: 1,
started_at: "2026-09-05T09:00:00Z".to_string(),
ended_at: Some("2026-09-05T17:00:00Z".to_string()),
}
}
fn event(kind: &str, body: &str, from_git: bool) -> RecentEvent {
RecentEvent {
kind: kind.to_string(),
date: "2026-09-05".to_string(),
body: body.to_string(),
from_git,
}
}
#[test]
fn events_are_sorted_by_what_they_are() {
let events = vec![
event("decision", "the tier carries its rhythm", false),
event("finding", "the grid wraps at 46 releases", false),
event("pitfall", "a byte index panics on a dash", false),
event("change", "added the calendar", false),
event("question", "which tier for dowel?", false),
];
let summary = summarise("alpha", &session(), &events, vec![], vec![], vec![], None);
assert_eq!(summary.decisions.len(), 1);
assert_eq!(summary.findings.len(), 1);
assert_eq!(summary.pitfalls.len(), 1);
assert_eq!(summary.changes.len(), 1);
assert_eq!(summary.questions.len(), 1);
assert_eq!(summary.recorded(), 5);
}
/// The chronicle read out of commit messages is what git already says,
/// not what the session did. Counting it would make every session that
/// happened to run `sync` look productive.
#[test]
fn the_chronicle_from_git_is_not_what_a_session_did() {
let events = vec![
event("change", "feat: read tags into facts", true),
event("change", "wrote the session summary", false),
];
let summary = summarise("alpha", &session(), &events, vec![], vec![], vec![], None);
assert_eq!(summary.changes, vec!["wrote the session summary"]);
assert_eq!(summary.recorded(), 1);
}
#[test]
fn a_session_that_recorded_nothing_says_so() {
let summary = summarise("alpha", &session(), &[], vec![], vec![], vec![], None);
assert!(summary.empty());
// And a session that only shipped is not empty: the tag is the work.
let shipped = summarise("alpha", &session(), &[], vec!["v0.1.0".to_string()], vec![], vec![], None);
assert!(!shipped.empty());
}
/// The two things the written ritual asks for by name, and the two most
/// often missing - because they are the last steps of a session, and a
/// session ends when attention has run out.
#[test]
fn the_end_names_what_the_ritual_asks_for_and_did_not_get() {
let bare = summarise("alpha", &session(), &[event("change", "did a thing", false)], vec![], vec![], vec![], None);
let missing = bare.missing();
assert!(missing.iter().any(|m| m.contains("next step")), "{missing:?}");
assert!(missing.iter().any(|m| m.contains("nothing says why")), "{missing:?}");
// With a reason recorded and a next step set, nothing is missing.
let whole = summarise(
"alpha",
&session(),
&[event("change", "did a thing", false), event("decision", "because", false)],
vec![],
vec![],
vec![],
Some("carry on".to_string()),
);
assert!(whole.missing().is_empty(), "{:?}", whole.missing());
}
/// A session with reasoning but no change has nothing to explain, so the
/// "why" complaint must not fire - otherwise a day spent deciding gets
/// told off for deciding.
#[test]
fn a_session_that_only_decided_is_not_asked_why() {
let summary = summarise(
"alpha",
&session(),
&[event("decision", "we will use ISO weeks", false)],
vec![],
vec![],
vec![],
Some("start on it".to_string()),
);
assert!(summary.missing().is_empty(), "{:?}", summary.missing());
}
#[test]
fn the_diary_entry_is_the_sessions_own_sentences() {
let summary = summarise(
"alpha",
&session(),
&[
event("change", "added the calendar", false),
event("decision", "the tier carries its rhythm", false),
event("pitfall", "the grid wraps at 46 releases", false),
],
vec!["v0.10.0".to_string()],
vec!["a task".to_string()],
vec!["feat(calendar): the grid".to_string()],
Some("ship the retro".to_string()),
);
let entry = diary_entry(&summary, "2026-09-05", Some("v0.10.0 «Календарь»"));
assert!(entry.starts_with("## 2026-09-05 · v0.10.0 «Календарь»"), "{entry}");
assert!(entry.contains("**Выпущено.** v0.10.0"), "{entry}");
assert!(entry.contains("added the calendar"), "{entry}");
assert!(entry.contains("the tier carries its rhythm"), "{entry}");
assert!(entry.contains("the grid wraps at 46 releases"), "{entry}");
// The closed task and the commit are part of what was done, in the
// same list as the recorded change, rather than a count beside it.
assert!(entry.contains("- a task"), "{entry}");
assert!(entry.contains("- feat(calendar): the grid"), "{entry}");
assert!(entry.contains("**Следующий шаг.** ship the retro"), "{entry}");
// An empty section is not printed as an empty heading.
assert!(!entry.contains("**Находки.**"), "{entry}");
}
#[test]
fn a_diary_entry_without_a_heading_still_names_its_day() {
let summary = summarise("alpha", &session(), &[event("change", "a thing", false)], vec![], vec![], vec![], None);
let entry = diary_entry(&summary, "2026-09-05", None);
assert!(entry.starts_with("## 2026-09-05\n"), "{entry}");
// A heading of nothing but spaces is the same as none.
let blank = diary_entry(&summary, "2026-09-05", Some(" "));
assert!(blank.starts_with("## 2026-09-05\n"), "{blank}");
}
/// A change recorded by hand usually repeats the commit it describes,
/// and the diary is read by a person: the same line twice reads as two
/// things done.
#[test]
fn what_was_done_is_said_once() {
let summary = summarise(
"alpha",
&session(),
&[event("change", "feat: the grid", false)],
vec![],
vec!["close the grid".to_string()],
vec!["feat: the grid".to_string(), "fix: a typo".to_string()],
None,
);
assert_eq!(summary.done(), vec!["close the grid", "feat: the grid", "fix: a typo"]);
// Commits alone are work: the session is not empty, and nothing it
// recorded is invented from them.
let only = summarise("alpha", &session(), &[], vec![], vec![], vec!["fix: a typo".to_string()], None);
assert!(!only.empty());
assert_eq!(only.recorded(), 0);
}
}