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
@@ -20,7 +20,7 @@ import { AGENT_ADDRESS_RE } from '../redaction-patterns.mjs';
20
20
  import { attributionRoots } from '../worktrees.mjs';
21
21
  import { localDateRangeInstants } from '../resolve-week.mjs';
22
22
  import { hostTimezone } from '../config.mjs';
23
- import { assemble, compareEvents } from './assemble.mjs';
23
+ import { assemble, compareEvents, threadsOf } from './assemble.mjs';
24
24
  import { parseClaudeSource } from './claude.mjs';
25
25
  import { codexOrigin, parseCodexSource } from './codex.mjs';
26
26
  import { isBuiltinToolName, LOOKUP_RULES, RULES, UPDATE_RULES } from './classify.mjs';
@@ -35,6 +35,7 @@ import { collapse, createWatch, LIMITS, redactClip, redactThenCut } from './pars
35
35
  import { defaultRoots, enumerateClaudeSources, enumerateCodexSources } from './sources.mjs';
36
36
  import { buildTimeline } from './timeline.mjs';
37
37
  import { createViews } from './views.mjs';
38
+ import { exportSession, fileFingerprint, honestweekVersion, idHash, labelHash, logIdHash, redactionPrint, restoreSaved, SAVED_SESSION_SCHEMA, savedAsParsed, tokenHash } from './saved-sessions.mjs';
38
39
 
39
40
  export { parseLookup } from './lookup.mjs';
40
41
 
@@ -49,8 +50,9 @@ export const HISTORY_SCHEMA_VERSION = 1;
49
50
  * scope 'configured' (default) or 'all'
50
51
  * quietMs shortest gap reported as a quiet interval (default 20 minutes)
51
52
  * git false skips the git outcome lookups (tests of parsing alone)
52
- * signal an optional { aborted } checked between files and before any git read: once
53
- * aborted, the build stops with an error and runs no git at all
53
+ * signal an optional { aborted } checked between files, before any git read, and once
54
+ * more after reading Codex's thread names: once aborted, the build stops with an
55
+ * error at the next check and runs no more git (none at all if it hadn't begun)
54
56
  * goals an optional goal record, { goals: [{ id, title, state?, source?,
55
57
  * observations?, results?, decisions? }], events?: [{ eventId, goalId,
56
58
  * type, at }] }. With it, the history gains `goals` (which sessions did
@@ -101,8 +103,23 @@ export const HISTORY_SCHEMA_VERSION = 1;
101
103
  * sessions get none, and spans, quiet intervals and end states read as without
102
104
  * it. Only `honestweek view` asks for it, for its own screen. Without it the
103
105
  * history doesn't change.
106
+ * saved sessions an earlier run saved (lib/replay/saved-sessions.mjs), each with
107
+ * `savedAt`, `version`, `redaction` (the private-word settings' fingerprint) and
108
+ * `log` ('gone' when none of its log files is on disk). One saved by this version
109
+ * with this config's private words would come out of the redactor unchanged, so it
110
+ * isn't redacted again; any other is:
111
+ * each one the build doesn't read from a log joins the history as if it had, with
112
+ * `saved: { at, by, log }` on its session record. Its steps can't be checked against
113
+ * their log lines. Without it the history doesn't change.
114
+ * saving true for a redacted build whose sessions will be saved: the history gains
115
+ * exportSession(key), not enumerable, so no output changes.
116
+ * reuse keys of sessions in `saved` whose logs are on disk and unchanged since they were
117
+ * saved: each comes back from `saved` instead of being read again, its steps still
118
+ * checked against its log lines. One that may have launched a session this build
119
+ * reads (it was running in the two minutes before) is read after all, since the
120
+ * launch is matched by a command's text, which isn't saved.
104
121
  */
105
- export async function buildWorkHistory({ config, from, to, timezone, roots, scope = 'configured', quietMs, git = true, goals: goalInput, privateText = false, hiddenSessions = 'skeleton', usage = false, keepRaw = false, updates = false, signal = null, onProgress = null } = {}) {
122
+ export async function buildWorkHistory({ config, from, to, timezone, roots, scope = 'configured', quietMs, git = true, goals: goalInput, privateText = false, hiddenSessions = 'skeleton', usage = false, keepRaw = false, updates = false, signal = null, onProgress = null, saved = null, saving = false, reuse = null } = {}) {
106
123
  if (!config?.identity) throw new Error('replay: a normalized config is required.');
107
124
  if (hiddenSessions !== 'skeleton' && hiddenSessions !== 'redacted') throw new Error(`replay: hiddenSessions is 'skeleton' or 'redacted', not ${JSON.stringify(hiddenSessions)}.`);
108
125
  // Private sessions keep their content: on this screen only (privateText), or redacted.
@@ -147,7 +164,42 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
147
164
  // One file reachable through two roots (overlapping or repeated roots) is one source.
148
165
  const seenKeys = new Set();
149
166
  const all = [...claude.sources, ...codex.sources].filter((s) => !seenKeys.has(s.key) && seenKeys.add(s.key));
167
+ // Saved sessions (lib/replay/saved-sessions.mjs): each one this build doesn't read from a log
168
+ // comes back with its strings redacted again with this build's settings; its step facts pass
169
+ // the backstop below with everything else.
170
+ let savedList = [];
171
+ // Sessions given back unchanged rather than read again: only ones `saved` holds.
172
+ // A session with a log file its saved copy doesn't name (a sub-agent or child thread that came
173
+ // later) has changed, and is read.
174
+ const reusable = new Set();
175
+ // A build that shows private text reads every log: a saved copy is redacted.
176
+ if (Array.isArray(saved) && reuse && privateText !== true) {
177
+ const wanted = new Set(reuse);
178
+ const onDisk = new Map();
179
+ for (const s of all) onDisk.set(s.sessionKey, [...(onDisk.get(s.sessionKey) ?? []), s.key]);
180
+ for (const x of saved) {
181
+ if (!x || typeof x.key !== 'string' || !wanted.has(x.key) || !Array.isArray(x.sources)) continue;
182
+ const savedKeys = new Set(x.sources.map((s) => s.key));
183
+ if ((onDisk.get(x.key) ?? []).every((k) => savedKeys.has(k))) reusable.add(x.key);
184
+ }
185
+ }
186
+ if (Array.isArray(saved) && saved.length) {
187
+ const readKeys = new Set(all.filter((s) => !reusable.has(s.sessionKey)).map((s) => s.sessionKey));
188
+ const print = redactionPrint(config);
189
+ const sameRedaction = (x) => x.version === honestweekVersion() && typeof x.redaction === 'string' && x.redaction === print;
190
+ const deepSaved = (x) => ({ ...deep({ ...x, events: [] }), events: x.events.map((e) => ({ ...deep({ ...e, facts: null }), facts: e.facts ?? {} })) });
191
+ for (const x of saved) {
192
+ if (x?.schema !== SAVED_SESSION_SCHEMA || typeof x.key !== 'string' || !Array.isArray(x.events) || !Array.isArray(x.sources) || readKeys.has(x.key) || savedList.some((y) => y.key === x.key)) continue;
193
+ // One saved by this version with these private words would come out of the redactor the same,
194
+ // so it isn't passed through it again; any other is.
195
+ savedList.push(sameRedaction(x) ? { ...x, redacted: true } : { ...deepSaved(x), redacted: false });
196
+ }
197
+ }
198
+ // With saved sessions in the build, ids two sessions share are compared as the hashes saved.
199
+ let hashIds = savedList.length > 0;
200
+ const repoOfSaved = (x) => (x.repo?.label ? config.repos.find((c) => labelHash(c.label) === x.repo.label) ?? null : null);
150
201
  const sessionRepo = new Map();
202
+ for (const x of savedList) sessionRepo.set(x.key, repoOfSaved(x));
151
203
  for (const s of all) {
152
204
  if (s.role !== 'session') continue;
153
205
  sessionRepo.set(s.sessionKey, matchRepo(s.cwd, config));
@@ -161,8 +213,12 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
161
213
  return true;
162
214
  });
163
215
  keep.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
216
+ // The sources of sessions given back unchanged are set aside, unread; their files stay known, so
217
+ // a step is still checked against its log line.
218
+ const setAside = keep.filter((s) => reusable.has(s.sessionKey));
219
+ if (setAside.length) for (let i = keep.length - 1; i >= 0; i -= 1) if (reusable.has(keep[i].sessionKey)) keep.splice(i, 1);
164
220
 
165
- const files = new Map(keep.map((s) => [s.key, s.file]));
221
+ const files = new Map([...keep, ...setAside].map((s) => [s.key, s.file]));
166
222
  const privateSources = new Set();
167
223
  // Sources whose content is cut to a skeleton: the private ones, unless privateText or
168
224
  // hiddenSessions 'redacted' keeps it.
@@ -178,7 +234,11 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
178
234
  }
179
235
  };
180
236
  progress(0);
181
- for (const s of keep) {
237
+ // Each log file's fingerprint as it was before this build read it, for a session to be saved
238
+ // with: one that grows while the build runs then reads as changed next time, not as saved.
239
+ const readPrints = new Map();
240
+ const readSource = async (s) => {
241
+ if (saving === true) readPrints.set(s.file, fileFingerprint(s.file));
182
242
  const repo = sessionRepo.get(s.sessionKey) ?? null;
183
243
  const isPrivate = !repo || repo.role === 'display';
184
244
  if (isPrivate) privateSources.add(s.key);
@@ -207,12 +267,87 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
207
267
  }
208
268
  parsed.push({ source: s, ctx, result });
209
269
  progress(parsed.length);
270
+ };
271
+ for (const s of keep) {
272
+ await readSource(s);
210
273
  if (signal?.aborted) break;
211
274
  }
212
- // Everything from here on runs without a pause, so this is the last point a stop can land.
275
+ // A session a program started is matched to the step that launched it by that step's command,
276
+ // which a saved session doesn't keep. A session given back unchanged that was running in the two
277
+ // minutes before one this build read began is read from its log after all.
278
+ if (setAside.length && !signal?.aborted) {
279
+ const starts = parsed.filter((q) => q.source.role === 'session' && q.result.joins?.programStart).map((q) => Date.parse(q.result.stats.firstAt ?? '')).filter(Number.isFinite);
280
+ const pull = new Set();
281
+ for (const x of savedList) {
282
+ if (!reusable.has(x.key)) continue;
283
+ const first = Date.parse(x.record?.firstAt ?? '');
284
+ const last = Date.parse(x.record?.lastAt ?? x.record?.firstAt ?? '');
285
+ if (starts.some((t0) => Number.isFinite(first) && first <= t0 + LAUNCH_SLACK_MS && (Number.isFinite(last) ? last : first) >= t0 - LAUNCH_LOOKBACK_MS)) pull.add(x.key);
286
+ }
287
+ for (const s of setAside) if (pull.has(s.sessionKey)) await readSource(s);
288
+ if (pull.size) {
289
+ savedList = savedList.filter((x) => !pull.has(x.key));
290
+ // With none left, the shared ids aren't hashed after all, and the sessions saved from this
291
+ // build say so.
292
+ hashIds = savedList.length > 0;
293
+ }
294
+ }
295
+ if (savedList.length) {
296
+ const savedSlugs = new Map();
297
+ // The goal ids and goal-event ids a saved session's text named, by their hashes.
298
+ const tokens = new Map([...(goalRecord?.entries ?? []).map((x) => x.eventId), ...(goalRecord?.goals ?? []).map((g) => g.id)].map((x) => [tokenHash(x), x]));
299
+ for (const x of savedList) {
300
+ const repo = sessionRepo.get(x.key) ?? null;
301
+ if (!repo && scope !== 'all') {
302
+ skipped.outsideConfiguredRepos += 1;
303
+ continue;
304
+ }
305
+ const isPrivate = !repo || repo.role === 'display';
306
+ const entries = savedAsParsed(x, {
307
+ resolveToken: (h) => (isPrivate ? null : tokens.get(h) ?? null),
308
+ // The session's repository's name on GitHub, read from git only for one git may read.
309
+ // Asked once per repository in a build, however many saved sessions name its pull requests.
310
+ repoSlug: () => {
311
+ if (!git || !repo || repo.role === 'display') return null;
312
+ if (!savedSlugs.has(repo.label)) {
313
+ let slug = null;
314
+ try {
315
+ slug = originSlug(repo.resolvedPath ?? repo.path);
316
+ } catch {
317
+ slug = null;
318
+ }
319
+ savedSlugs.set(repo.label, slug);
320
+ }
321
+ return savedSlugs.get(repo.label);
322
+ },
323
+ ctxOf: (source, s) => ({ redact, isPrivate, privateText: privateText === true, hiddenSessions, usage: usage === true, keepRaw: false, updates: updates === true, cwd: null, repo: repo ? { label: repo.label, role: repo.role, path: repo.resolvedPath ?? repo.path } : null, agentKey: s.agent ?? (source.role === 'session' ? `${source.key}:main` : source.key), sidechainAgentKey: null, parentAgentKey: null, watch: null }),
324
+ });
325
+ for (const e of entries) {
326
+ if (isPrivate) privateSources.add(e.source.key);
327
+ if (x.log !== 'gone' && files.has(e.source.key)) e.source.file = files.get(e.source.key);
328
+ parsed.push(e);
329
+ }
330
+ }
331
+ // In the order a build that read every one of them would have.
332
+ parsed.sort((x, y) => (x.source.key < y.source.key ? -1 : x.source.key > y.source.key ? 1 : 0));
333
+ for (const q of parsed) {
334
+ if (q.source.saved) continue;
335
+ const j = q.result.joins ?? {};
336
+ for (const u of j.uuids ?? []) u.uuid = idHash(u.uuid);
337
+ for (const m of [...(j.sentMessages ?? []), ...(j.receivedMessages ?? [])]) m.msgId = idHash(m.msgId);
338
+ }
339
+ }
340
+ // A stop lands here, before git is read, and once more after reading Codex's thread names,
341
+ // the one pause left: everything after that runs without one.
213
342
  if (signal?.aborted) throw Object.assign(new Error('replay: the build was stopped.'), { code: 'ABORTED' });
214
343
 
215
344
  const a = assemble({ parsed, quietMs });
345
+ if (savedList.length) {
346
+ restoreSaved(a, savedList.filter((x) => a.sessionsByKey.has(x.key)));
347
+ // A link between sessions that came back as saved can join threads assembly kept apart.
348
+ a.threads = threadsOf(a.sessionsByKey, a.links, new Map(parsed.map((p) => [p.source.key, p.source.sessionKey])));
349
+ }
350
+ const savedOf = new Map(savedList.map((x) => [x.key, x]));
216
351
 
217
352
  // Git: only for sessions in featured or reference repos.
218
353
  let gitPart = { events: [], sources: [], notes: [] };
@@ -232,6 +367,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
232
367
  // redacted one by one where it was read (lib/replay/codex.mjs), so the home-path rule doesn't
233
368
  // take it here.
234
369
  const codexSources = new Set(parsed.filter((p) => p.source.tool === 'codex').map((p) => p.source.key));
370
+ const alreadyRedacted = new Set(savedList.filter((x) => x.redacted === true).flatMap((x) => x.sources.map((s) => s.key)));
235
371
  for (const e of a.events) {
236
372
  if (hiddenSources.has(e.source)) {
237
373
  e.facts = pick(e.facts, PRIVATE_FACTS);
@@ -248,7 +384,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
248
384
  for (const key of PRIVATE_HASHES) delete e.facts[key];
249
385
  }
250
386
  const addresses = codexSources.has(e.source) ? ADDRESS_FACTS.filter((k) => typeof e.facts[k] === 'string' && AGENT_ADDRESS_RE.test(e.facts[k])).map((k) => [k, e.facts[k]]) : [];
251
- e.facts = deep(e.facts);
387
+ if (!alreadyRedacted.has(e.source)) e.facts = deep(e.facts);
252
388
  for (const [k, v] of addresses) e.facts[k] = v;
253
389
  }
254
390
  // The progress-update rule labels events only with the updates option.
@@ -267,7 +403,9 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
267
403
 
268
404
  // Sessions and agents as plain records.
269
405
  const codexNames = await codexThreadNames(r.codex ?? [], new Set(parsed.filter((p) => p.source.tool === 'codex' && p.source.role === 'session').map((p) => p.source.threadId)));
406
+ if (signal?.aborted) throw Object.assign(new Error('replay: the build was stopped.'), { code: 'ABORTED' });
270
407
  const titleOf = (sessKey) => {
408
+ if (savedOf.has(sessKey)) return savedOf.get(sessKey).record?.title ?? null;
271
409
  const main = parsed.find((p) => p.source.key === sessKey);
272
410
  if (main?.source.tool === 'codex') return redactClip(codexNames.get(main.source.threadId) ?? null, SHORT, redact);
273
411
  const titles = main?.result.stats.titles ?? [];
@@ -294,6 +432,8 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
294
432
  repoRole: s.repo?.role ?? null,
295
433
  private: s.isPrivate,
296
434
  title: s.isPrivate && !showHidden ? null : titleOf(s.key),
435
+ // A session that came back as saved says when, by which version, and whether its log is gone.
436
+ ...(savedOf.has(s.key) && savedOf.get(s.key).log === 'gone' ? { saved: savedMeta(savedOf.get(s.key)) } : {}),
297
437
  firstAt: s.firstAt ?? null,
298
438
  lastAt: s.lastAt ?? null,
299
439
  endState: s.endState ?? 'no-work-records',
@@ -351,7 +491,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
351
491
  };
352
492
  if (privateText === true) h.privateText = true;
353
493
  if (hiddenSessions === 'redacted') h.hiddenSessions = 'redacted';
354
- if (usage === true) h.usage = { calls: usageCalls(parsed) };
494
+ if (usage === true) h.usage = { calls: usageCalls(parsed, { hashIds: hashIds || saving === true }) };
355
495
  const views = createViews(h);
356
496
 
357
497
  // Goal membership and reverse lookup read the raw joins the adapters kept (moved onto
@@ -425,6 +565,15 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
425
565
  const id = p.source.fileId ?? p.source.threadId;
426
566
  if (typeof id === 'string' && id) harnessIds.set(id.toLowerCase(), p.source.sessionKey);
427
567
  }
568
+ // A saved session's own id is saved only as a hash: the goal list's own words are hashed to
569
+ // find the ones that cite it.
570
+ const savedIds = new Map(savedList.filter((x) => x.logId && readable.has(x.key)).map((x) => [x.logId, x.key]));
571
+ if (savedIds.size) {
572
+ for (const word of new Set(JSON.stringify(goalInput).match(/[0-9A-Za-z][0-9A-Za-z_-]{7,}/g) ?? [])) {
573
+ const key = savedIds.get(logIdHash(word));
574
+ if (key && !harnessIds.has(word.toLowerCase())) harnessIds.set(word.toLowerCase(), key);
575
+ }
576
+ }
428
577
  const found = goalMembership({ record: goalRecord, index: refIndex(), watched: liveJoins('watched'), harnessIds, readable, firstAtOf, window: h.window, redact: redactor.redact });
429
578
  h.goals = redactor.deepRedact(found);
430
579
  goalByRawId = new Map(goalRecord.goals.map((g, i) => [g.id, h.goals[i]]));
@@ -462,6 +611,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
462
611
 
463
612
  // Each event by its id, built on the first record() call, so a lookup doesn't scan every
464
613
  // event. The first event with an id wins, as a search from the front would find it.
614
+ const savedSourceSession = new Map(savedList.flatMap((x) => x.sources.map((s) => [s.key, x.key])));
465
615
  let eventById = null;
466
616
  /** Re-read the original record behind a reference and check its digest. */
467
617
  function record(refOrEventId) {
@@ -473,6 +623,7 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
473
623
  return refs.map((ref) => {
474
624
  if (ref.sha) return { ref, verified: null, note: 'a git object; inspect it with git show', record: null };
475
625
  const file = files.get(ref.src);
626
+ if (!file && savedSourceSession.has(ref.src) && savedOf.get(savedSourceSession.get(ref.src))?.log === 'gone') return { ref, verified: null, note: savedNote(savedOf.get(savedSourceSession.get(ref.src))), record: null, saved: true };
476
627
  if (!file) return { ref, verified: false, note: 'source not part of this history', record: null };
477
628
  const bytes = readBytesAt(file, ref.off, ref.len);
478
629
  if (!bytes) return { ref, verified: false, note: 'the record could not be re-read (file shorter or moved)', record: null };
@@ -520,7 +671,23 @@ export async function buildWorkHistory({ config, from, to, timezone, roots, scop
520
671
  // Each Claude Code session's own id (its file's name), by session key, in memory only, so a
521
672
  // caller can find files another tool keyed by that id. Not enumerable: no output changes.
522
673
  Object.defineProperty(history, 'claudeIds', { value: new Map(parsed.filter((p) => p.source.tool === 'claude-code' && p.source.role === 'session' && typeof p.source.fileId === 'string').map((p) => [p.source.sessionKey, p.source.fileId.toLowerCase()])), enumerable: false });
674
+ // Each log file this history read, by source key, in memory only, so a caller can tell later
675
+ // whether a session's log is still on disk (lib/saved/checks.mjs keeps only a hash of the path).
676
+ Object.defineProperty(history, 'sourceFiles', { value: new Map(files), enumerable: false });
523
677
  // The same for each Codex session: its thread id from session_meta, in memory only.
678
+ // Each saved session's own id, as the hash it was saved under, in memory only.
679
+ // The sessions that came back unread, their logs unchanged: their saved copies stand as they are.
680
+ // Each log file's fingerprint from before this build read it (a build made with `saving` only).
681
+ Object.defineProperty(history, 'sourcePrints', { value: new Map(readPrints), enumerable: false });
682
+ Object.defineProperty(history, 'reusedSessions', { value: new Set(savedList.filter((x) => reusable.has(x.key)).map((x) => x.key)), enumerable: false });
683
+ Object.defineProperty(history, 'savedLogIds', { value: new Map(savedList.filter((x) => x.logId).map((x) => [x.key, x.logId])), enumerable: false });
684
+ if (saving === true && privateText !== true) {
685
+ const savable = new Set(parsed.filter((p) => !p.source.saved).map((p) => p.source.sessionKey));
686
+ Object.defineProperty(history, 'exportSession', {
687
+ value: (key) => (savable.has(key) ? exportSession({ key, parsed, a, sessions, links: h.links, usage: h.usage?.calls ?? [], redact, hashed: hashIds, ranOutside: (e) => typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir), fileStat: (f) => readPrints.get(f) ?? null }) : null),
688
+ enumerable: false,
689
+ });
690
+ }
524
691
  Object.defineProperty(history, 'codexIds', { value: new Map(parsed.filter((p) => p.source.tool === 'codex' && p.source.role === 'session' && typeof p.source.threadId === 'string').map((p) => [p.source.sessionKey, p.source.threadId.toLowerCase()])), enumerable: false });
525
692
  return history;
526
693
  }
@@ -641,7 +808,22 @@ async function codexThreadNames(roots, ids) {
641
808
  return out;
642
809
  }
643
810
 
644
- function usageCalls(parsed) {
811
+ /** How long before a session began, and after, a launch step that started it can sit. */
812
+ const LAUNCH_LOOKBACK_MS = 2 * 60 * 1000;
813
+ const LAUNCH_SLACK_MS = 5000;
814
+
815
+ /** When and by which version a saved session was saved, and whether its log is gone. */
816
+ function savedMeta(x) {
817
+ return { at: typeof x.savedAt === 'string' ? x.savedAt : null, by: typeof x.version === 'string' ? x.version : null, log: x.log === 'gone' ? 'gone' : 'on-disk' };
818
+ }
819
+ /** What a saved step's log line says instead of the record. */
820
+ function savedNote(x) {
821
+ // The day it was saved is on the session's own label, in the page's timezone.
822
+ const m = x ? savedMeta(x) : { log: 'gone' };
823
+ return m.log === 'gone' ? "saved from a log that's no longer on disk, so this step can't be checked against its log line" : "saved; this run didn't read its log again";
824
+ }
825
+
826
+ function usageCalls(parsed, { hashIds = false } = {}) {
645
827
  const lastAt = (p) => p.result.stats?.lastAt ?? '';
646
828
  const earlier = (p, q) => lastAt(p) < lastAt(q) || (lastAt(p) === lastAt(q) && p.source.key < q.source.key);
647
829
  const byThread = new Map(parsed.filter((p) => p.source.tool === 'codex').map((p) => [p.source.threadId, p]));
@@ -671,7 +853,10 @@ function usageCalls(parsed) {
671
853
  };
672
854
  const keeps = (p, q) => (p.source.tool === 'codex' && depth(p) !== depth(q) ? depth(p) < depth(q) : earlier(p, q));
673
855
  const owner = new Map(); // tool, fork family and call id -> the parsed file that keeps it
674
- const keyOf = (p, c) => (p.source.tool === 'codex' ? `codex\u0000${family(p)}\u0000${c.id}` : `${p.source.tool}\u0000${c.id}`);
856
+ // A saved session's call ids are hashes already; with saved sessions in the build, every other
857
+ // call id is compared as its hash too, so a call two files repeat still counts once.
858
+ const idOf = (c) => (c.hashed ? c.id : hashIds && c.id != null ? idHash(c.id) : c.id);
859
+ const keyOf = (p, c) => (p.source.tool === 'codex' ? `codex\u0000${family(p)}\u0000${idOf(c)}` : `${p.source.tool}\u0000${idOf(c)}`);
675
860
  for (const p of parsed) {
676
861
  for (const c of p.result.usage ?? []) {
677
862
  if (c.id == null) continue;
@@ -683,7 +868,10 @@ function usageCalls(parsed) {
683
868
  for (const p of parsed) {
684
869
  for (const c of p.result.usage ?? []) {
685
870
  if (c.id != null && owner.get(keyOf(p, c)) !== p) continue;
686
- out.push({ session: p.source.sessionKey, agent: c.agent, source: p.source.key, tool: p.source.tool, lines: [...c.lines].sort((a, b) => a - b), t: c.t, input: c.input, cacheWrite: c.cacheWrite, cacheRead: c.cacheRead, output: c.output, ...(c.ttl ? { ttl: c.ttl } : {}) });
871
+ const row = { session: p.source.sessionKey, agent: c.agent, source: p.source.key, tool: p.source.tool, lines: [...c.lines].sort((a, b) => a - b), t: c.t, input: c.input, cacheWrite: c.cacheWrite, cacheRead: c.cacheRead, output: c.output, ...(c.ttl ? { ttl: c.ttl } : {}) };
872
+ // The call's id as it's saved, kept out of the row's own fields so no output changes.
873
+ if (hashIds && c.id != null) Object.defineProperty(row, 'idHash', { value: idOf(c), enumerable: false });
874
+ out.push(row);
687
875
  }
688
876
  }
689
877
  return out.sort((a, b) => (a.t ?? 0) - (b.t ?? 0) || (a.source < b.source ? -1 : a.source > b.source ? 1 : a.lines[0] - b.lines[0]));
@@ -189,11 +189,14 @@ export function createReferenceIndex({ events, readable, joins, repoSlugOf, work
189
189
  for (const e of events) {
190
190
  if (!readable.has(e.session)) continue;
191
191
  const f = e.facts ?? {};
192
- if (e.kind === 'action' && f.category === 'shell' && typeof e._command === 'string' && !didNotRun(e)) {
192
+ // A saved step (lib/replay/saved-sessions.mjs) brings its command's pull requests as they were
193
+ // read when it was saved, since the command's text isn't kept.
194
+ const commandRefs = Array.isArray(e._prRefs) ? e._prRefs : typeof e._command === 'string' ? null : undefined;
195
+ if (e.kind === 'action' && f.category === 'shell' && commandRefs !== undefined && !didNotRun(e)) {
193
196
  // A Codex call that ran in a folder outside the session's repository acts on
194
197
  // whatever repository that folder holds, which the record doesn't say.
195
- const ranOutside = typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir);
196
- for (const r of prRefsInCommand(e._command)) {
198
+ const ranOutside = commandRefs ? e._ranOutside === true : typeof e._workdir === 'string' && !workdirInRepo(e.session, e._workdir);
199
+ for (const r of commandRefs ?? prRefsInCommand(e._command)) {
197
200
  if (!r.repoKnown) continue;
198
201
  if (!r.repo && ranOutside) continue;
199
202
  const slug = r.repo ? repoSlug(r.repo) : repoSlugOf(e.session);