honestweek 0.2.0 → 0.3.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.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -2
  3. package/README.md +252 -197
  4. package/SKILL.md +25 -74
  5. package/bin/honestweek.mjs +81 -24
  6. package/flows/client.md +19 -0
  7. package/flows/digest.md +11 -0
  8. package/flows/mine.md +18 -0
  9. package/flows/view.md +45 -0
  10. package/flows/weekly.md +29 -0
  11. package/lib/ask.mjs +1142 -0
  12. package/lib/build.mjs +12 -3
  13. package/lib/config-lookup.mjs +179 -0
  14. package/lib/config.mjs +20 -0
  15. package/lib/demo/week.mjs +3 -0
  16. package/lib/digest-carry.mjs +3 -2
  17. package/lib/digest-store.mjs +3 -2
  18. package/lib/digest.mjs +21 -14
  19. package/lib/discover.mjs +28 -22
  20. package/lib/emit/index.mjs +18 -6
  21. package/lib/harvest.mjs +19 -1
  22. package/lib/history.mjs +10 -2
  23. package/lib/init.mjs +241 -55
  24. package/lib/mine.mjs +18 -8
  25. package/lib/preview.mjs +66 -13
  26. package/lib/private-words.mjs +25 -5
  27. package/lib/problems/index.mjs +60 -7
  28. package/lib/prompt-lane.mjs +14 -13
  29. package/lib/prompt-store.mjs +2 -1
  30. package/lib/prompts.mjs +13 -7
  31. package/lib/replay/assemble.mjs +25 -8
  32. package/lib/replay/index.mjs +200 -12
  33. package/lib/replay/lookup.mjs +6 -3
  34. package/lib/replay/saved-sessions.mjs +315 -0
  35. package/lib/replay/views.mjs +12 -1
  36. package/lib/{view → replay}/word-index.mjs +3 -3
  37. package/lib/replay/words.mjs +122 -0
  38. package/lib/repo-identity.mjs +81 -13
  39. package/lib/saved/checks.mjs +381 -0
  40. package/lib/saved/history.mjs +235 -0
  41. package/lib/saved/saver.mjs +111 -0
  42. package/lib/saved/store.mjs +256 -0
  43. package/lib/status.mjs +288 -0
  44. package/lib/validate.mjs +12 -3
  45. package/lib/view/assets/common.css +2 -0
  46. package/lib/view/assets/common.js +20 -2
  47. package/lib/view/assets/problems.js +4 -3
  48. package/lib/view/assets/replay.js +3 -1
  49. package/lib/view/assets/search.js +1 -1
  50. package/lib/view/assets/sessions.js +1 -0
  51. package/lib/view/assets/settings.html +16 -0
  52. package/lib/view/assets/settings.js +108 -4
  53. package/lib/view/assets/setup.html +7 -0
  54. package/lib/view/assets/setup.js +9 -0
  55. package/lib/view/codex-judge.mjs +1 -1
  56. package/lib/view/data.mjs +160 -76
  57. package/lib/view/own-week.mjs +119 -0
  58. package/lib/view/page-link.mjs +84 -0
  59. package/lib/view/problems-route.mjs +29 -3
  60. package/lib/view/replay-export.mjs +1 -1
  61. package/lib/view/selftest/clickthrough.js +9 -5
  62. package/lib/view/server.mjs +3 -1
  63. package/lib/view/settings.mjs +102 -34
  64. package/lib/view/setup.mjs +27 -14
  65. package/lib/view/suggest-words.mjs +67 -0
  66. package/lib/view.mjs +82 -133
  67. package/lib/worktrees.mjs +31 -18
  68. package/package.json +2 -1
@@ -0,0 +1,381 @@
1
+ // lib/saved/checks.mjs: the Problems checks' results, saved day by day (issue 151, first part).
2
+ //
3
+ // When "saveResults" is on, `honestweek view` saves what the checks found once the whole window
4
+ // has loaded: one file per day, checks/<day>.json in the saved folder (lib/saved/store.mjs),
5
+ // holding the sessions that started that day in the config's timezone. A session belongs to the
6
+ // day it started, whole, even when it runs past midnight. Each session says whether the checks
7
+ // read it (only sessions in configured repositories are), and each finding keeps the evidence
8
+ // word it had. A session in a display-only repository or a folder outside the config keeps only
9
+ // its id, its times and that it wasn't checked: no title, no repository, no finding.
10
+ //
11
+ // A later run replaces each day it covers, keeping any saved session it didn't read: that
12
+ // session's log is gone, or was moved, and what was saved is all there is of it. Days older than
13
+ // "keepDays" are deleted as it saves.
14
+ //
15
+ // Nothing saved is raw: every string passed the full redactor before it was written, and passes
16
+ // it again, with the reading run's settings, every time it's read, so a private word added later
17
+ // is hidden in old days too. A log's own session id is kept only as a hash (idHashOf), and a log
18
+ // file only as the hash of its path (pathKey), which is enough to tell whether it's still on disk.
19
+ //
20
+ // `honestweek problems --session <id>` reads these for a session that isn't in its window,
21
+ // through findSaved and savedSessionAnswer.
22
+ //
23
+ // Zero runtime dependencies: Node built-ins only.
24
+
25
+ import { createRedactor } from '../redact.mjs';
26
+ import { letterHash, pathKey as pathKeyOf } from '../replay/ids.mjs';
27
+ import { fileFingerprint } from '../replay/saved-sessions.mjs';
28
+ import { localDay } from '../replay/views.mjs';
29
+ import { RULES as ENGINE_RULES } from '../replay/classify.mjs';
30
+ import { LAUNCH_RULES } from '../replay/launch.mjs';
31
+ import { isSure, RULES as CHECK_RULES, weakest } from '../problems/index.mjs';
32
+ import { redactAnswer } from '../view/problems-route.mjs';
33
+ import { sessionGroup } from '../view/replay-export.mjs';
34
+ import { localDateRangeInstants } from '../resolve-week.mjs';
35
+ import { THRESHOLDS } from '../problems/context.mjs';
36
+ import { honestweekVersion, idHashOf, keptFrom, logKeys, logPaths, pruneSaved, readSaved, redactionPrint, savedDays, SAVED_SCHEMA, writeSaved } from './store.mjs';
37
+
38
+ /** A fingerprint of the config settings the checks read (the long-session limit): findings saved
39
+ * under other settings aren't taken back for sessions whose logs are still on disk. */
40
+ export function checksPrint(config) {
41
+ return letterHash(`checks\u0000${JSON.stringify({ longSessionTokens: Number.isInteger(config?.longSessionTokens) ? config.longSessionTokens : null })}`, 16);
42
+ }
43
+
44
+ /** Whether a saved session had been quiet for THRESHOLDS.stillRunningMs when its checks ran, so
45
+ * none of its findings was softened for a session that may still be running. */
46
+ export const settledWhenChecked = (s, last) => Number.isFinite(s?.checkedAt) && Number.isFinite(last) && s.checkedAt - last >= THRESHOLDS.stillRunningMs;
47
+
48
+ /** The sub-folder of the saved folder that holds the check results. */
49
+ export const CHECKS_SUB = 'checks';
50
+ /** The shortest start of a session's key a saved lookup reads as that key (as in view's data). */
51
+ export const SAVED_PREFIX_MIN = 8;
52
+
53
+ /** Saved fields whose value is honestweek's own id, hash, time, date or word: kept when it has
54
+ * that shape, and redacted like any other string when it doesn't. */
55
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
56
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
57
+ const SHAPES = {
58
+ idHash: /^[a-p]{24}$/,
59
+ files: /^f-[a-p]{12}$/,
60
+ file: /^f-[a-p]{12}$/,
61
+ head: /^[a-p]{16}$/,
62
+ redaction: /^[a-p]{16}$/,
63
+ checks: /^[a-p]{16}$/,
64
+ days: DAY,
65
+ day: DAY,
66
+ savedAt: ISO,
67
+ firstAt: ISO,
68
+ version: /^[0-9A-Za-z][0-9A-Za-z.+-]{0,39}$/,
69
+ tool: /^(?:claude-code|codex)$/,
70
+ group: /^(?:configured|display|outside)$/,
71
+ status: /^(?:found|clear|unchecked|undetectable)$/,
72
+ ran: /^[a-z0-9][a-z0-9-]{0,63}$/,
73
+ severity: /^(?:look|note)$/,
74
+ verdictEvidence: /^(?:recorded|derived|inferred|missing|ambiguous)$/,
75
+ level: /^(?:recorded|derived|inferred|missing|ambiguous)$/,
76
+ };
77
+
78
+ /**
79
+ * Every string in a saved value through `redact`, except one under a key above with that key's
80
+ * shape, and the ids, times and dates redactAnswer keeps (lib/view/problems-route.mjs). Keys are
81
+ * this file's own names, never log text, so they stay as they are.
82
+ */
83
+ export function redactSaved(value, redact, key = null, depth = 0) {
84
+ if (depth > 40) return null;
85
+ if (typeof value === 'string' && SHAPES[key]?.test(value)) return value;
86
+ if (Array.isArray(value)) return value.map((v) => redactSaved(v, redact, key, depth + 1));
87
+ if (value && typeof value === 'object') {
88
+ const out = {};
89
+ for (const [k, v] of Object.entries(value)) out[k] = redactSaved(v, redact, k, depth + 1);
90
+ return out;
91
+ }
92
+ return redactAnswer(value, redact, key, depth);
93
+ }
94
+
95
+ const ALL_RULES = new Map([...ENGINE_RULES, ...LAUNCH_RULES, ...Object.entries(CHECK_RULES)]);
96
+ const ruleIdsIn = (text) => (typeof text === 'string' ? text.split(/[^A-Za-z0-9._-]+/).filter((id) => ALL_RULES.has(id)) : []);
97
+
98
+ /** A finding as it's saved: every field its check recorded, its step described from this build. */
99
+ function savedFinding(f, key, describeStep) {
100
+ // Every field the check recorded, so a saved finding answers as the live one did, plus its key and
101
+ // its step as this build described it.
102
+ const out = {};
103
+ for (const [k, v] of Object.entries(f)) if (!k.startsWith('_') && v !== undefined) out[k] = v;
104
+ return { ...out, key: key ?? null, text: f.event ? describeStep(f.event) : null };
105
+ }
106
+
107
+ /**
108
+ * saveChecks({ dir, configDir, config, h, result, keys, describeStep, keepDays, now }) ->
109
+ * { days, sessions, findings, pruned }
110
+ * h the window's redacted history (the build the checks read)
111
+ * result, keys runProblems(h) and each finding's key (lib/view/problems-route.mjs)
112
+ * describeStep event id -> the step's description in this build, or null
113
+ * Saves each day of h's window, from its first day to its last. Throws only when the folder
114
+ * can't be written; the caller says so and the page goes on.
115
+ */
116
+ export function saveChecks({ dir, config, h, result, keys = new Map(), describeStep = () => null, keepDays, builtT = null, now = Date.now() }) {
117
+ const tz = h.window.timezone;
118
+ const { from, to } = h.window;
119
+ const redactor = createRedactor(config);
120
+ const redact = (s) => redactor.redact(String(s));
121
+ const savedAt = new Date(now).toISOString();
122
+ const version = honestweekVersion();
123
+ const redaction = redactionPrint(config);
124
+ const ownIds = new Map([...(h.claudeIds ?? new Map()), ...(h.codexIds ?? new Map())]);
125
+ const filesOf = new Map();
126
+ for (const [src, file] of h.sourceFiles ?? new Map()) {
127
+ const s = h.sourceSession?.get(src);
128
+ if (!s) continue;
129
+ if (!filesOf.has(s)) filesOf.set(s, new Set());
130
+ filesOf.get(s).add(file);
131
+ }
132
+ // The days each session has a step on, for the per-day counts a later trend reads.
133
+ const daysOf = new Map();
134
+ for (const e of h.events) {
135
+ if (!e.session || !Number.isFinite(e.t) || e.t < h.window.startT || e.t >= h.window.endT) continue;
136
+ if (!daysOf.has(e.session)) daysOf.set(e.session, new Set());
137
+ daysOf.get(e.session).add(localDay(e.t, tz));
138
+ }
139
+ // Each pattern's state in this window: found, clear, unchecked or undetectable.
140
+ // Each pattern's state in this window, and for one whose check counts tokens, how it counts them,
141
+ // so a later run that takes these sessions back unread can say the same.
142
+ const status = result.patterns.map((p) => ({ id: p.id, status: p.status, ran: p.measures.filter((m) => m.ran).map((m) => m.check), level: weakest(p.measures.filter((m) => m.ran).map((m) => m.evidence)), ...(p.tokens ? { tokens: { waste: p.tokens.waste, label: p.tokens.label, evidence: p.tokens.evidence } } : {}) }));
143
+ const findingsOf = new Map();
144
+ for (const p of result.patterns) {
145
+ for (const f of p.findings) {
146
+ if (!f.session) continue;
147
+ if (!findingsOf.has(f.session)) findingsOf.set(f.session, []);
148
+ findingsOf.get(f.session).push(savedFinding(f, keys.get(f) ?? null, describeStep));
149
+ }
150
+ }
151
+
152
+ const byDay = new Map();
153
+ for (const s of h.sessions) {
154
+ // A session that came back as saved, or unread because its logs hadn't changed, keeps the entry
155
+ // it was saved with.
156
+ if (!s.firstAt || s.saved || h.reusedSessions?.has(s.key)) continue;
157
+ const day = localDay(Date.parse(s.firstAt), tz);
158
+ if (day < from || day > to) continue;
159
+ const group = sessionGroup(s);
160
+ const checked = group === 'configured';
161
+ const id = ownIds.get(s.key);
162
+ const findings = checked ? findingsOf.get(s.key) ?? [] : [];
163
+ const entry = {
164
+ key: s.key,
165
+ idHash: idHashOf(id),
166
+ tool: s.tool ?? null,
167
+ group,
168
+ checked,
169
+ ...(checked ? { title: s.title ?? null, repo: typeof s.repo === 'string' ? s.repo : null } : {}),
170
+ firstAt: s.firstAt,
171
+ lastAt: s.lastAt ?? null,
172
+ days: [...(daysOf.get(s.key) ?? [])].sort(),
173
+ // The stretch of time the checks covered (this run's window), so a later run takes the session
174
+ // back unread only when its findings covered the whole session.
175
+ checks: checksPrint(config),
176
+ // When the checks ran: a session that wrote a record within THRESHOLDS.stillRunningMs of then
177
+ // had its last turn's findings softened to notes, which a later read wouldn't do.
178
+ checkedAt: Number.isFinite(builtT) ? builtT : null,
179
+ checkedFrom: h.window.startT,
180
+ checkedTo: h.window.endT,
181
+ files: [...(filesOf.get(s.key) ?? [])].map((f) => pathKeyOf(f)).filter(Boolean).sort(),
182
+ // Each of those files' fingerprint as the build read it, so a later trend can tell it hasn't changed.
183
+ prints: [...(filesOf.get(s.key) ?? [])].map((f) => ({ file: pathKeyOf(f), ...(h.sourcePrints?.get(f) ?? {}) })).filter((x) => x.file && Number.isFinite(x.size)).sort((a, b) => (a.file < b.file ? -1 : 1)),
184
+ findings: findings.length,
185
+ look: findings.filter((f) => f.severity === 'look').length,
186
+ };
187
+ if (!byDay.has(day)) byDay.set(day, { sessions: [], findings: [] });
188
+ byDay.get(day).sessions.push(entry);
189
+ byDay.get(day).findings.push(...findings);
190
+ }
191
+
192
+ let sessions = 0;
193
+ let findings = 0;
194
+ const days = [];
195
+ // A day already past keepDays isn't written: it would be deleted as soon as it was saved.
196
+ const today = localDay(now, tz);
197
+ const oldest = keptFrom(today, keepDays);
198
+ for (let day = from; day <= to; day = nextDay(day)) {
199
+ if (oldest && day < oldest) continue;
200
+ const fresh = byDay.get(day) ?? { sessions: [], findings: [] };
201
+ const had = readDay(dir, day);
202
+ // A session saved with a log file this run didn't read keeps its fuller saved entry (as in
203
+ // lib/saved/history.mjs), findings and all. Files are counted, not matched by path: a Codex
204
+ // log that was archived has moved, not shrunk, and its fresh entry replaces the saved one.
205
+ const oldFiles = new Map((had?.sessions ?? []).filter((s) => s && typeof s.key === 'string').map((s) => [s.key, Array.isArray(s.files) ? s.files : []]));
206
+ const partial = new Set(fresh.sessions.filter((s) => oldFiles.has(s.key) && oldFiles.get(s.key).length > s.files.length).map((s) => s.key));
207
+ if (partial.size) {
208
+ fresh.sessions = fresh.sessions.filter((s) => !partial.has(s.key));
209
+ fresh.findings = fresh.findings.filter((f) => !partial.has(f.session));
210
+ }
211
+ const freshKeys = new Set(fresh.sessions.map((s) => s.key));
212
+ // A session saved before that this run didn't read stays, with its findings, as it was saved.
213
+ const keptSessions = (had?.sessions ?? []).filter((s) => s && typeof s.key === 'string' && !freshKeys.has(s.key));
214
+ const keptKeys = new Set(keptSessions.map((s) => s.key));
215
+ const keptFindings = (had?.findings ?? []).filter((f) => keptKeys.has(f?.session));
216
+ const all = [...fresh.sessions.map((s) => ({ ...s, savedAt, version, ...(s.checked ? { status } : {}) })), ...keptSessions].sort((a, b) => String(a.firstAt).localeCompare(String(b.firstAt)) || (a.key < b.key ? -1 : 1));
217
+ const allFindings = [...fresh.findings, ...keptFindings];
218
+ // The text of each rule a finding names, as this version states it; a rule a kept finding names
219
+ // that this version no longer has keeps the text it was saved with.
220
+ const named = new Set(allFindings.flatMap((f) => ruleIdsIn(f.rule)));
221
+ const rules = [...named].sort().map((id) => ({ id, text: ALL_RULES.get(id) }));
222
+ for (const r of had?.rules ?? []) if (r && typeof r.id === 'string' && !ALL_RULES.has(r.id) && keptFindings.some((f) => String(f.rule ?? '').split(/[^A-Za-z0-9._-]+/).includes(r.id))) rules.push({ id: r.id, text: r.text });
223
+ const value = redactSaved({ schema: SAVED_SCHEMA, day, timezone: tz, savedAt, version, redaction, window: { from, to }, status, rules, sessions: all, findings: allFindings }, redact);
224
+ writeSaved(dir, [CHECKS_SUB, `${day}.json`], value);
225
+ days.push(day);
226
+ sessions += all.length;
227
+ findings += allFindings.length;
228
+ }
229
+ const pruned = pruneSaved(dir, CHECKS_SUB, { keepDays, today });
230
+ return { days, sessions, findings, pruned };
231
+ }
232
+
233
+ const nextDay = (day) => new Date(Date.parse(`${day}T00:00:00Z`) + 86400000).toISOString().slice(0, 10);
234
+
235
+
236
+ /** One saved day, or null when it's missing or not the shape this file writes: a day that isn't
237
+ * is replaced whole by the next save rather than stopping it. */
238
+ function readDay(dir, day) {
239
+ const f = readSaved(dir, [CHECKS_SUB, `${day}.json`]);
240
+ return f && Array.isArray(f.sessions) && Array.isArray(f.findings) ? f : null;
241
+ }
242
+
243
+ /** Every saved session, newest day first: [{ day, session }]. */
244
+ function savedSessions(dir) {
245
+ const out = [];
246
+ for (const day of savedDays(dir, CHECKS_SUB).reverse()) {
247
+ const f = readDay(dir, day);
248
+ for (const s of f?.sessions ?? []) if (s && typeof s.key === 'string') out.push({ day, session: s });
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * findSaved(dir, text) -> { key, day } | { matches: [key...] } | { none: true }
255
+ * A saved session by its key, the start of its key (SAVED_PREFIX_MIN characters or more), or
256
+ * its log's own full id (matched by hash, since the id itself is never saved).
257
+ */
258
+ export function findSaved(dir, text) {
259
+ const want = String(text ?? '').trim().toLowerCase();
260
+ if (!want || want.length > 200) return { none: true };
261
+ const all = savedSessions(dir);
262
+ const byKey = new Map();
263
+ for (const x of all) if (!byKey.has(x.session.key)) byKey.set(x.session.key, x.day);
264
+ if (byKey.has(want)) return { key: want, day: byKey.get(want) };
265
+ const hash = idHashOf(want);
266
+ const byId = all.find((x) => x.session.idHash === hash);
267
+ if (byId) return { key: byId.session.key, day: byId.day };
268
+ if (want.length < SAVED_PREFIX_MIN) return { none: true };
269
+ const keys = [...byKey.keys()].filter((k) => k.startsWith(want)).sort();
270
+ if (keys.length === 1) return { key: keys[0], day: byKey.get(keys[0]) };
271
+ return keys.length ? { matches: keys } : { none: true };
272
+ }
273
+
274
+ /**
275
+ * savedSessionAnswer({ dir, config, roots, key, day }) -> the saved session and its findings,
276
+ * every string redacted again with `config`'s settings, or null when it's no longer saved:
277
+ * { session, findings, rules, status, savedAt, version, logOnDisk }
278
+ * `status` is each pattern's state in the run that checked this session, or null when that run
279
+ * saved none (a session the checks didn't read).
280
+ * `logOnDisk` is true when one of its log files is still under `roots`, false when none is, and
281
+ * null when it named none.
282
+ */
283
+ export function savedSessionAnswer({ dir, config, roots, key, day, onDisk: given = null }) {
284
+ const f = readDay(dir, day);
285
+ const s = f?.sessions.find((x) => x?.key === key);
286
+ if (!s) return null;
287
+ const redactor = createRedactor(config);
288
+ const redact = (x) => redactor.redact(String(x));
289
+ const files = Array.isArray(s.files) ? s.files : [];
290
+ const onDisk = given ?? (files.length ? logKeys(roots) : null);
291
+ const answer = redactSaved({
292
+ session: s,
293
+ findings: (f.findings ?? []).filter((x) => x?.session === key),
294
+ rules: Array.isArray(f.rules) ? f.rules : [],
295
+ status: Array.isArray(s.status) ? s.status : null,
296
+ savedAt: s.savedAt ?? f.savedAt ?? null,
297
+ version: s.version ?? f.version ?? null,
298
+ }, redact);
299
+ return { ...answer, logOnDisk: files.length ? files.some((k) => onDisk.has(k)) : null };
300
+ }
301
+
302
+
303
+ /**
304
+ * countsFromSaved({ dir, from, to, timezone, roots, version }) -> trend counts, or null
305
+ * What trendCounts (lib/problems/index.mjs) gives for a window, worked out from the check results
306
+ * saved for it instead of reading its logs again: the sessions the checks read with a step in it,
307
+ * and each pattern's findings worth a look whose moment is in it, split into worked out from the
308
+ * log and possible, each with its weakest evidence word. Null, so the window is read instead, when
309
+ * a day of it was never saved or was saved before it ended, a session in it ran past the window
310
+ * its checks covered, or a session in it whose log is still on disk was saved by another
311
+ * honestweek version (its log is read with this one's rules) or written to since it was saved.
312
+ */
313
+ export function countsFromSaved({ dir, from, to, timezone, roots, version, config = null }) {
314
+ const settings = config ? checksPrint(config) : null;
315
+ const days = [];
316
+ for (let day = from; day <= to; day = nextDay(day)) days.push(day);
317
+ // Every day saved after it ended, so no session that started later in it is missing.
318
+ if (!days.every((day) => {
319
+ const f = readDay(dir, day);
320
+ return f && Date.parse(f.savedAt ?? '') >= localDateRangeInstants(day, day, timezone).endExclusive.getTime();
321
+ })) return null;
322
+ const { start, endExclusive } = localDateRangeInstants(from, to, timezone);
323
+ const inRange = (day) => day >= from && day <= to;
324
+ const sessions = new Map();
325
+ const findings = [];
326
+ // A session that started up to a week before can still have steps in the window.
327
+ for (let day = nextDayBy(from, -7); day <= to; day = nextDay(day)) {
328
+ const f = readDay(dir, day);
329
+ if (!f) continue;
330
+ for (const s of f.sessions) {
331
+ if (!s?.checked || sessions.has(s.key) || !(Array.isArray(s.days) && s.days.some(inRange))) continue;
332
+ sessions.set(s.key, s);
333
+ }
334
+ for (const x of f.findings) if (x && sessions.has(x.session)) findings.push(x);
335
+ }
336
+ let onDisk = null;
337
+ for (const s of sessions.values()) {
338
+ // The checks that saved it covered its part of this window.
339
+ const first = Date.parse(s.firstAt ?? '');
340
+ const last = Date.parse(s.lastAt ?? s.firstAt ?? '');
341
+ if (!Number.isFinite(s.checkedFrom) || !Number.isFinite(s.checkedTo) || s.checkedFrom > Math.max(first, start.getTime()) || s.checkedTo < Math.min(last, endExclusive.getTime())) return null;
342
+ onDisk ??= logPaths(roots);
343
+ const present = (s.files ?? []).filter((k) => onDisk.has(k));
344
+ if (s.version !== version && present.length) return null;
345
+ // Checked under other settings (another long-session limit) with its log still there: read it.
346
+ if (present.length && settings && s.checks !== settings) return null;
347
+ // Checked while it may still have been running, its last turn's findings were notes: read it.
348
+ if (present.length && !settledWhenChecked(s, last)) return null;
349
+ // A log changed since the session was saved holds steps its saved results don't.
350
+ const prints = new Map((Array.isArray(s.prints) ? s.prints : []).map((x) => [x?.file, x]));
351
+ if (present.some((k) => {
352
+ const was = prints.get(k);
353
+ const fp = fileFingerprint(onDisk.get(k));
354
+ return !was || !fp || fp.size !== was.size || fp.mtimeMs !== was.mtimeMs || fp.head !== was.head;
355
+ })) return null;
356
+ }
357
+ const ranOf = new Map();
358
+ for (const s of sessions.values()) {
359
+ for (const x of Array.isArray(s.status) ? s.status : []) {
360
+ const r = ranOf.get(x.id) ?? { ran: false, levels: [] };
361
+ if (x.status === 'found' || x.status === 'clear') r.ran = true;
362
+ if (x.level) r.levels.push(x.level);
363
+ ranOf.set(x.id, r);
364
+ }
365
+ }
366
+ const looks = findings.filter((x) => x.severity === 'look' && x.at && inRange(localDay(Date.parse(x.at), timezone)));
367
+ const patterns = {};
368
+ for (const [id, r] of ranOf) {
369
+ const mine = looks.filter((x) => x.pattern === id);
370
+ const sure = mine.filter(isSure);
371
+ const possible = mine.filter((x) => !isSure(x));
372
+ patterns[id] = {
373
+ ran: r.ran,
374
+ sure: { value: sure.length, evidence: sure.length ? weakest(sure.map((x) => x.verdictEvidence)) : 'derived' },
375
+ possible: { value: possible.length, evidence: possible.length ? weakest(possible.map((x) => x.verdictEvidence)) : weakest(r.levels) },
376
+ };
377
+ }
378
+ return { sessions: sessions.size, patterns };
379
+ }
380
+
381
+ const nextDayBy = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
@@ -0,0 +1,235 @@
1
+ // lib/saved/history.mjs: each day's history, saved day by day (issue 151, second part).
2
+ //
3
+ // With "saveResults" on and its "history" not turned off, `honestweek view` saves, beside the
4
+ // check results (lib/saved/checks.mjs), what the engine built for each session that started that
5
+ // day: its steps, agents, links, token counts, and the joins that tie it to other sessions, goals
6
+ // and git (lib/replay/saved-sessions.mjs says exactly what, and what's left out). One gzipped file
7
+ // per day, days/<day>.json.gz in the saved folder, compressed with Node's own zlib.
8
+ //
9
+ // A later run gives the engine back each saved session whose log is gone, so it stays replayable,
10
+ // on the sessions list, in Replay, on Problems with the findings saved with it, in goals and in
11
+ // pull-request and commit lookups. Each one says when it was saved, by which honestweek version,
12
+ // and that its log is gone. A session whose log is still on disk is read from the log, as always.
13
+ //
14
+ // What's written is the redacted build's output, never the private build's, and it's redacted
15
+ // again with the reading run's settings when it comes back (in the engine). A later run replaces
16
+ // each day it read, keeping any saved session it didn't read.
17
+ //
18
+ // Zero runtime dependencies: Node built-ins only.
19
+
20
+ import { existsSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { gunzipSync, gzipSync } from 'node:zlib';
23
+
24
+ import { localDateRangeInstants } from '../resolve-week.mjs';
25
+ import { localDay } from '../replay/views.mjs';
26
+ import { fileFingerprint, SAVED_SESSION_SCHEMA } from '../replay/saved-sessions.mjs';
27
+ import { CHECKS_SUB, checksPrint, settledWhenChecked } from './checks.mjs';
28
+ import { honestweekVersion, keptFrom, logPaths, MAX_SAVED_FILE, pruneSaved, readSaved, redactionPrint, SAVED_SCHEMA, savedDays, writeSavedBytes } from './store.mjs';
29
+
30
+ /** The sub-folder of the saved folder that holds each day's history. */
31
+ export const DAYS_SUB = 'days';
32
+ /** The largest a saved day may be once unzipped; a larger one is read as missing. */
33
+ export const MAX_DAY_BYTES = 512 * 1024 * 1024;
34
+ /** How many days before a window a session that runs into it may have started, and still be
35
+ * found in its start day's file. */
36
+ export const LOOKBACK_DAYS = 7;
37
+ /** A session that wrote a record this recently is read again even when its log hasn't changed:
38
+ * the checks say a finding near the end of one that may still be running is only a note. */
39
+ export const RECENT_MS = 24 * 60 * 60 * 1000;
40
+
41
+ const shift = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
42
+ const dayFile = (day) => `${day}.json.gz`;
43
+
44
+ /** One saved day's history, or null when it's missing, too big, unreadable or of another shape. */
45
+ export function readHistoryDay(dir, day) {
46
+ const path = join(dir, DAYS_SUB, dayFile(day));
47
+ try {
48
+ const st = statSync(path);
49
+ if (!st.isFile() || st.size > MAX_SAVED_FILE) return null;
50
+ const text = gunzipSync(readFileSync(path), { maxOutputLength: MAX_DAY_BYTES }).toString('utf8');
51
+ const value = JSON.parse(text);
52
+ return value && value.schema === SAVED_SCHEMA && Array.isArray(value.sessions) ? value : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * saveHistory({ dir, config, h, keepDays, others, now }) -> { days, sessions }
60
+ * Each day of h's window, from its first day to its last: the sessions h read from logs that
61
+ * started that day, as the engine exports them, and any saved session of that day h didn't read.
62
+ * A day already past keepDays isn't written. h must be the redacted build, made with `saving`.
63
+ */
64
+ export function saveHistory({ dir, config, h, keepDays, others = false, now = Date.now() }) {
65
+ if (typeof h.exportSession !== 'function') return { days: [], sessions: 0 };
66
+ const tz = h.window.timezone;
67
+ const { from, to } = h.window;
68
+ const savedAt = new Date(now).toISOString();
69
+ const version = honestweekVersion();
70
+ const redaction = redactionPrint(config);
71
+ const today = localDay(now, tz);
72
+ const oldest = keptFrom(today, keepDays);
73
+ const byDay = new Map();
74
+ for (const s of h.sessions) {
75
+ if (!s.firstAt || s.saved || h.reusedSessions?.has(s.key)) continue;
76
+ // A session from a display-only repository or a folder outside the config is saved only when
77
+ // the config says so ("otherSessions"): by default only configured repositories' history is.
78
+ if (s.private && !others) continue;
79
+ const day = localDay(Date.parse(s.firstAt), tz);
80
+ if (day < from || day > to || (oldest && day < oldest)) continue;
81
+ const x = h.exportSession(s.key);
82
+ if (!x) continue;
83
+ if (!byDay.has(day)) byDay.set(day, []);
84
+ byDay.get(day).push({ ...x, savedAt, version, redaction });
85
+ }
86
+ const days = [];
87
+ let sessions = 0;
88
+ for (let day = from; day <= to; day = shift(day, 1)) {
89
+ if (oldest && day < oldest) continue;
90
+ const fresh = byDay.get(day) ?? [];
91
+ const had = readHistoryDay(dir, day);
92
+ // A day file that's there but can't be read (damaged, too big, or of a newer shape) may hold
93
+ // the only copy of sessions whose logs are gone: it's left as it is, not written over.
94
+ if (!had && existsSync(join(dir, DAYS_SUB, dayFile(day)))) continue;
95
+ // A saved session that named a log file this run didn't read (a sub-agent's log deleted before
96
+ // the session's own) keeps its fuller saved copy rather than the part read now.
97
+ const fuller = new Map((had?.sessions ?? []).filter((x) => x && typeof x.key === 'string').map((x) => [x.key, x]));
98
+ const keepsOld = (x) => {
99
+ const old = fuller.get(x.key);
100
+ if (!old) return false;
101
+ const now = new Set((x.sources ?? []).map((s) => s.key));
102
+ return (old.sources ?? []).some((s) => !now.has(s.key));
103
+ };
104
+ const freshUsed = fresh.filter((x) => !keepsOld(x));
105
+ // A day this run read nothing new for is left as it is (unless "otherSessions" was turned off
106
+ // and it holds one of those to drop).
107
+ if (had && !freshUsed.length && (others || !had.sessions.some((x) => x?.record?.private === true))) continue;
108
+ const freshKeys = new Set(freshUsed.map((x) => x.key));
109
+ // With otherSessions off, a display-only or outside session saved while it was on goes too.
110
+ const kept = (had?.sessions ?? []).filter((x) => x && typeof x.key === 'string' && !freshKeys.has(x.key) && (others || x.record?.private !== true));
111
+ const all = [...freshUsed, ...kept].sort((a, b) => String(a.record?.firstAt).localeCompare(String(b.record?.firstAt)) || (a.key < b.key ? -1 : 1));
112
+ const value = { schema: SAVED_SCHEMA, day, timezone: tz, savedAt, version, redaction, sessions: all };
113
+ writeSavedBytes(dir, [DAYS_SUB, dayFile(day)], gzipSync(Buffer.from(JSON.stringify(value), 'utf8')));
114
+ days.push(day);
115
+ sessions += all.length;
116
+ }
117
+ const pruned = pruneSaved(dir, DAYS_SUB, { keepDays, today });
118
+ return { days, sessions, pruned };
119
+ }
120
+
121
+ /** With history off, the days saved while it was on are still deleted once past keepDays. */
122
+ export function pruneHistory({ dir, h, keepDays, now = Date.now() }) {
123
+ return pruneSaved(dir, DAYS_SUB, { keepDays, today: localDay(now, h.window.timezone) });
124
+ }
125
+
126
+ /**
127
+ * loadSaved({ dir, from, to, timezone, roots, config, others, now }) -> { sessions, findings, reuse }
128
+ * The saved sessions whose span overlaps from..to (read in `timezone`) that a build can take back
129
+ * instead of reading a log, for buildWorkHistory's `saved` and `reuse`:
130
+ * - each one none of whose log files is on disk any more, marked log: 'gone';
131
+ * - each one whose log files are all on disk, unchanged since it was saved (size, last change
132
+ * and a hash of their first bytes), saved by this honestweek version with these private-word
133
+ * settings, that ended more than RECENT_MS before `now`, and whose check results were saved
134
+ * too, from checks that covered all of it, marked log: 'on-disk' and named in `reuse`.
135
+ * Any other saved session is left to the log, read again. `findings` holds the findings the checks
136
+ * saved with each session taken back (an empty list for one with none), for runProblems' `saved`,
137
+ * and `statuses` each one's pattern states when it was checked, for its `savedStatus`. With
138
+ * `others` false, a display-only or outside session saved while "otherSessions" was on stays
139
+ * out.
140
+ */
141
+ export function loadSaved({ dir, from, to, timezone, roots, config = null, others = true, now = Date.now() }) {
142
+ const { start, endExclusive } = localDateRangeInstants(from, to, timezone);
143
+ const startT = start.getTime();
144
+ const endT = endExclusive.getTime();
145
+ const version = honestweekVersion();
146
+ const print = config ? redactionPrint(config) : null;
147
+ const sessions = [];
148
+ const findings = new Map();
149
+ const statuses = new Map();
150
+ const reuse = new Set();
151
+ let onDisk = null;
152
+ const seen = new Set();
153
+ const checksOf = new Map();
154
+ const checksDay = (day) => {
155
+ if (!checksOf.has(day)) {
156
+ const c = readSaved(dir, [CHECKS_SUB, `${day}.json`]);
157
+ checksOf.set(day, c && Array.isArray(c.sessions) && Array.isArray(c.findings) ? c : null);
158
+ }
159
+ return checksOf.get(day);
160
+ };
161
+ // A finding counts only when its moment is in the window, as the checks would find it now.
162
+ const inWindow = (y) => {
163
+ const t = Date.parse(y?.at ?? '');
164
+ return !Number.isFinite(t) || (t >= startT && t < endT);
165
+ };
166
+ for (let day = shift(from, -LOOKBACK_DAYS); day <= to; day = shift(day, 1)) {
167
+ const f = readHistoryDay(dir, day);
168
+ if (!f) continue;
169
+ for (const x of f.sessions) {
170
+ if (!x || x.schema !== SAVED_SESSION_SCHEMA || typeof x.key !== 'string' || seen.has(x.key) || (!others && x.record?.private === true)) continue;
171
+ const first = Date.parse(x.record?.firstAt ?? '');
172
+ const last = Date.parse(x.record?.lastAt ?? x.record?.firstAt ?? '');
173
+ if (!Number.isFinite(first) || first >= endT || (Number.isFinite(last) ? last : first) < startT) continue;
174
+ const files = (x.sources ?? []).map((s) => s.file).filter(Boolean);
175
+ onDisk ??= logPaths(roots);
176
+ const present = (x.sources ?? []).filter((s) => s.file && onDisk.has(s.file));
177
+ // The day it was saved under holds its check results, whatever the timezone is now.
178
+ const c = checksDay(f.day);
179
+ const checked = c?.sessions.find((s) => s?.key === x.key) ?? null;
180
+ const own = c ? c.findings.filter((y) => y?.session === x.key && inWindow(y)) : [];
181
+ if (files.length && !present.length) {
182
+ seen.add(x.key);
183
+ sessions.push({ ...x, log: 'gone', redaction: typeof x.redaction === 'string' ? x.redaction : null });
184
+ findings.set(x.key, own);
185
+ if (Array.isArray(checked?.status)) statuses.set(x.key, checked.status);
186
+ continue;
187
+ }
188
+ const unchanged = present.length === (x.sources ?? []).length && present.every((s) => {
189
+ const fp = fileFingerprint(onDisk.get(s.file));
190
+ return fp && fp.size === s.size && fp.mtimeMs === s.mtimeMs && fp.head === s.head;
191
+ });
192
+ const settled = Number.isFinite(last) && now - last > RECENT_MS;
193
+ // The checks that saved its findings covered all of it: a session that ran past the end of the
194
+ // window it was checked in has steps no check looked at.
195
+ const covered = Number.isFinite(checked?.checkedFrom) && Number.isFinite(checked?.checkedTo) && checked.checkedFrom <= first && last < checked.checkedTo;
196
+ // And under the settings the checks read now (the long-session limit).
197
+ const sameChecks = !config || checked?.checks === checksPrint(config);
198
+ // And checked once it had been quiet long enough that no finding was softened to a note.
199
+ if (!unchanged || !settled || !checked || !covered || !sameChecks || !settledWhenChecked(checked, last) || x.version !== version || (print && x.redaction !== print)) continue;
200
+ seen.add(x.key);
201
+ sessions.push({ ...x, log: 'on-disk', redaction: x.redaction });
202
+ findings.set(x.key, own);
203
+ if (Array.isArray(checked.status)) statuses.set(x.key, checked.status);
204
+ reuse.add(x.key);
205
+ }
206
+ }
207
+ return { sessions, findings, statuses, reuse };
208
+ }
209
+
210
+ /** How many bytes of logs a byte of a saved day stands for, in memory once it's loaded: on the
211
+ * made-up demo week, 6.1 MB of logs saved as 194 KB of gzipped days (about 31 to 1). An estimate. */
212
+ export const SAVED_LOG_RATIO = 30;
213
+
214
+ /**
215
+ * savedDayFiles(dir, logs) -> [{ day, size }]
216
+ * The saved days with no log file of their own (in `logs`, as logFiles lists them), each sized as
217
+ * the logs it stands for (SAVED_LOG_RATIO), so the window's plan and its memory check count the
218
+ * sessions a saved day brings back. Empty when nothing's saved.
219
+ */
220
+ export function savedDayFiles(dir, logs = []) {
221
+ const withLogs = new Set(logs.map((f) => f.day));
222
+ const out = [];
223
+ for (const day of savedDays(dir, DAYS_SUB)) {
224
+ if (withLogs.has(day)) continue;
225
+ try {
226
+ out.push({ day, size: statSync(join(dir, DAYS_SUB, dayFile(day))).size * SAVED_LOG_RATIO });
227
+ } catch {
228
+ /* gone since the listing */
229
+ }
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /** The log files and the saved days with no log of their own, as one list (savedDayFiles). */
235
+ export const withSavedDays = (logs, dir) => [...logs, ...savedDayFiles(dir, logs)];