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,315 @@
1
+ // lib/replay/saved-sessions.mjs: sessions the engine didn't read from a log this run, given to it
2
+ // as they were saved by an earlier run (issue 151).
3
+ //
4
+ // `honestweek view` can keep each day's history between runs (lib/saved/). A session saved that
5
+ // way comes back into a later build, as if its log had been read, once its log is gone (Claude
6
+ // Code deletes logs after 30 days by default). The engine takes such a session through
7
+ // `buildWorkHistory({ saved })`, and everything downstream (threads, goals, lookups, git
8
+ // outcomes, timelines, the checks' context) works on it as on any other. This module turns a
9
+ // built session into what's saved, and what's saved back into the engine's own shapes.
10
+ //
11
+ // What's saved is the redacted build's own output for the session: its steps, agents, links,
12
+ // token counts, and the joins that connect it to other sessions and to goals and git (pull
13
+ // requests, commits, branches). Nothing raw: fields the engine keeps in memory only (an event's
14
+ // `_raw`, `_command`, `_workdir`, `_lineCwd`) are dropped; record ids, message ids and token call
15
+ // ids are kept only as hashes; a goal id found in its text is kept as a hash; a log file only as
16
+ // the hash of its path. Git outcomes and quiet intervals aren't saved: the build works them out
17
+ // again. When saved sessions come back, every string passes the current redactor again.
18
+ //
19
+ // Ids a saved session shares with another session (record ids a resumed copy repeats, a message's
20
+ // id) are compared as hashes, so with `saved` in play the engine hashes the fresh sessions' ids
21
+ // the same way before it joins them. Without `saved`, nothing here runs.
22
+
23
+ import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
24
+
25
+ import { prRefsInCommand } from './classify.mjs';
26
+ import { pathKey, letterHash } from './ids.mjs';
27
+
28
+ /** The shape of a saved session; one of another is left out of a build. */
29
+ export const SAVED_SESSION_SCHEMA = 1;
30
+
31
+ /** An id as saved and as compared once saved sessions are in a build. */
32
+ export const idHash = (s) => letterHash(`id\u0000${String(s)}`, 24);
33
+ /** A log's own session id (a Claude Code file id, a Codex thread id), as saved. */
34
+ export const logIdHash = (id) => (typeof id === 'string' && id ? letterHash(`log-id\u0000${id.toLowerCase()}`, 24) : null);
35
+ let VERSION = null;
36
+ /** honestweek's own version, from its package.json, which says which version saved a session. */
37
+ export function honestweekVersion() {
38
+ if (VERSION === null) {
39
+ try {
40
+ VERSION = String(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version ?? 'unknown');
41
+ } catch {
42
+ VERSION = 'unknown';
43
+ }
44
+ }
45
+ return VERSION;
46
+ }
47
+
48
+ /** A fingerprint of the private-word settings a session was saved with: a hash, never the words. */
49
+ export function redactionPrint(config) {
50
+ return letterHash(`redaction\u0000${JSON.stringify(config?.redaction ?? null)}`, 16);
51
+ }
52
+
53
+ /** How many bytes from a log's start its fingerprint reads. */
54
+ export const HEAD_BYTES = 4096;
55
+
56
+ /**
57
+ * A log file's fingerprint: { size, mtimeMs, head }, `head` a hash of its first HEAD_BYTES bytes,
58
+ * so a file replaced by another of the same size and time still reads as changed. Null when it
59
+ * can't be read.
60
+ */
61
+ export function fileFingerprint(path) {
62
+ if (typeof path !== 'string' || !path) return null;
63
+ let fd;
64
+ try {
65
+ const st = statSync(path);
66
+ if (!st.isFile()) return null;
67
+ const buf = Buffer.alloc(Math.min(HEAD_BYTES, st.size));
68
+ fd = openSync(path, 'r');
69
+ const n = buf.length ? readSync(fd, buf, 0, buf.length, 0) : 0;
70
+ return { size: st.size, mtimeMs: st.mtimeMs, head: letterHash(buf.subarray(0, n), 16) };
71
+ } catch {
72
+ return null;
73
+ } finally {
74
+ if (fd !== undefined) {
75
+ try {
76
+ closeSync(fd);
77
+ } catch {
78
+ /* already closed */
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /** A repository label, as saved: the config's label is matched to it on the way back. */
85
+ export const labelHash = (label) => (typeof label === 'string' ? letterHash(`repo-label\u0000${label}`, 16) : null);
86
+ /** A goal id or goal-event id found in a session's text, as saved. */
87
+ export const tokenHash = (token) => letterHash(`goal-token\u0000${String(token)}`, 24);
88
+
89
+ /** The links the engine makes from one session's own records, which a saved session brings back
90
+ * as saved, since the joins behind them aren't saved. */
91
+ const OWN_LINKS = new Set(['spawned', 'completion-notice', 'stopped', 'handback', 'program-launch']);
92
+ /** Links between sessions a saved session also brings back: a resumed copy's records were let go
93
+ * when it was saved, and a message's two ends may not both be in this build. The engine makes
94
+ * them again too, where both sessions are here; each is kept once. */
95
+ const SHARED_LINKS = new Set(['continuation', 'message-delivered', 'handoff']);
96
+ /** A pull request's repository as it's matched on the way back, without its name on disk. */
97
+ export const repoNameHash = (repo) => (typeof repo === 'string' && repo ? letterHash(`repo\u0000${repo.toLowerCase()}`, 24) : null);
98
+
99
+ /** A pull-request, commit or branch join as saved: every field but its step, which is named by id,
100
+ * each string redacted except a commit id. */
101
+ function joinOut(x, redact) {
102
+ const out = {};
103
+ for (const [k, v] of Object.entries(x)) {
104
+ if (k === 'event' || k.startsWith('_')) continue;
105
+ out[k] = typeof v === 'string' && k !== 'sha' ? redact(v) : v;
106
+ }
107
+ if (typeof x.repo === 'string' && x.repo) out.repoHash = repoNameHash(x.repo);
108
+ out.event = x.event.id;
109
+ return out;
110
+ }
111
+
112
+ /** What git says about a step, which every build asks git again rather than keeping. */
113
+ const GIT_MISSING = new Set(['readable-session-repository', 'commit-in-session-repository', 'default-branch']);
114
+
115
+ const ownFields = (e) => {
116
+ const out = {};
117
+ for (const [k, v] of Object.entries(e)) if (!k.startsWith('_')) out[k] = v;
118
+ if (Array.isArray(out.missing)) out.missing = out.missing.filter((m) => !GIT_MISSING.has(m));
119
+ return out;
120
+ };
121
+
122
+ /**
123
+ * exportSession({ key, parsed, a, sessions, links, usage, redact, fileStat }) -> a saved session
124
+ * The redacted build's own parts for one session, ready to write. `a` is the assembled history,
125
+ * `sessions` the history's session records, `usage` its token calls. Null for a session the
126
+ * build doesn't hold. `hashed` says the build already hashed the shared ids (it had saved sessions).
127
+ */
128
+ export function exportSession({ key, parsed, a, sessions, links, usage, redact, fileStat = () => null, ranOutside = () => false, hashed = false }) {
129
+ const record = sessions.find((s) => s.key === key);
130
+ if (!record) return null;
131
+ // With saved sessions in the build, the engine has already hashed these ids for the join.
132
+ const hashOnce = hashed ? (x) => x : idHash;
133
+ const mine = parsed.filter((p) => p.source.sessionKey === key);
134
+ const main = mine.find((p) => p.source.role === 'session') ?? null;
135
+ const sourceKeys = new Set(mine.map((p) => p.source.key));
136
+ const events = a.events.filter((e) => e.session === key && sourceKeys.has(e.source) && e.kind !== 'quiet').map(ownFields);
137
+ const eventIds = new Set(events.map((e) => e.id));
138
+ const agents = [...a.agents.values()].filter((x) => x.session === key);
139
+ const agentKeys = new Set(agents.map((x) => x.key));
140
+ const touches = (v) => typeof v === 'string' && (eventIds.has(v) || agentKeys.has(v) || sourceKeys.has(v) || v === key);
141
+ const ownLinks = links.filter((l) => touches(l.from) || touches(l.to) || (Array.isArray(l.sessions) && l.sessions.includes(key)));
142
+ const live = (x) => x?.event && eventIds.has(x.event.id);
143
+ // A join is kept once across files, so one on this session's step can sit in another file's
144
+ // list (a resumed copy's): every file's joins are looked through.
145
+ const j = (k) => parsed.flatMap((p) => p.result.joins?.[k] ?? []).filter(live);
146
+ const repo = a.sessionsByKey.get(key)?.repo ?? null;
147
+ const id = main ? (main.source.tool === 'codex' ? main.source.threadId : main.source.fileId) : null;
148
+ return {
149
+ schema: SAVED_SESSION_SCHEMA,
150
+ key,
151
+ tool: record.tool,
152
+ record,
153
+ repo: repo ? { label: labelHash(repo.label), role: repo.role ?? null } : null,
154
+ logId: logIdHash(id),
155
+ sources: mine.map((p) => {
156
+ const st = fileStat(p.source.file);
157
+ return {
158
+ key: p.source.key,
159
+ tool: p.source.tool,
160
+ role: p.source.role,
161
+ agent: p.ctx.agentKey,
162
+ file: pathKey(p.source.file),
163
+ size: st?.size ?? p.source.size ?? null,
164
+ mtimeMs: st?.mtimeMs ?? null,
165
+ head: st?.head ?? null,
166
+ firstAt: p.result.stats.firstAt ?? p.source.firstAt ?? null,
167
+ lastAt: p.result.stats.lastAt ?? p.source.lastAt ?? null,
168
+ records: p.result.stats.records ?? 0,
169
+ unparsable: p.result.stats.unparsable ?? 0,
170
+ oversized: p.result.stats.oversized ?? 0,
171
+ models: p.result.stats.models ?? [],
172
+ entrypoint: p.result.stats.entrypoint ?? null,
173
+ origin: p.result.stats.origin ? { kind: p.result.stats.origin.kind ?? null } : null,
174
+ // How each record type in the file was handled, and what reading it noticed.
175
+ coverage: [...(p.result.coverage ?? new Map())].map(([k, v]) => [redact(k), { count: v.count, handling: redact(v.handling) }]),
176
+ anomalies: p.result.anomalies ?? [],
177
+ };
178
+ }),
179
+ events,
180
+ agents,
181
+ links: ownLinks,
182
+ // What assembly noticed about its steps (a result stamped before its call, say).
183
+ anomalies: a.anomalies.filter((x) => eventIds.has(x.event)),
184
+ usage: usage.filter((c) => c.session === key).map((c) => ({ ...c, id: c.idHash ?? null })),
185
+ joins: {
186
+ uuids: mine.flatMap((p) => p.result.joins?.uuids ?? []).map((u) => ({ uuid: hashOnce(u.uuid), eventIds: u.eventIds })),
187
+ // A record this session's file repeats from another (a resumed copy) was let go in favour of
188
+ // the original; its id, kind and place are kept, so the original can still say it was copied.
189
+ copies: mine.flatMap((p) => p.result.events).filter((e) => !eventIds.has(e.id) && e.kind !== 'quiet').map((e) => ({ id: e.id, source: e.source, kind: e.kind, refs: e.refs ?? [] })),
190
+ sentMessages: j('sentMessages').map((m) => ({ msgId: hashOnce(m.msgId), event: m.event.id })),
191
+ receivedMessages: j('receivedMessages').map((m) => ({ msgId: hashOnce(m.msgId), event: m.event.id })),
192
+ chips: j('chips').map((c) => ({ digest: c.digest, event: c.event.id })),
193
+ prs: j('prs').map((x) => joinOut(x, redact)),
194
+ commits: j('commits').map((x) => joinOut(x, redact)),
195
+ branches: j('branches').map((x) => joinOut(x, redact)),
196
+ watched: j('watched').map((x) => ({ token: tokenHash(x.token), where: x.where, event: x.event.id })),
197
+ // The pull requests a gh command names, read from the command when it was saved, since the
198
+ // command's own text isn't kept: each repository redacted, with a hash to match it back.
199
+ commandPrs: a.events
200
+ .filter((e) => e.session === key && eventIds.has(e.id) && e.kind === 'action' && e.facts?.category === 'shell' && typeof e._command === 'string')
201
+ .map((e) => ({ event: e.id, ranOutside: ranOutside(e), refs: prRefsInCommand(e._command).map((r) => ({ number: r.number, via: r.via, repoKnown: r.repoKnown, repo: typeof r.repo === 'string' ? redact(r.repo) : r.repo ?? null, ...(typeof r.repo === 'string' && r.repo ? { repoHash: repoNameHash(r.repo) } : {}) })) }))
202
+ .filter((x) => x.refs.length),
203
+ firstPromptDigest: main?.result.joins?.firstPromptDigest ?? null,
204
+ firstPromptEvent: main && eventIds.has(main.result.joins?.firstPromptEvent) ? main.result.joins.firstPromptEvent : null,
205
+ },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The engine's parsed entries for one saved session, as if its sources had been read: each with
211
+ * the saved steps, the joins the engine matches across sessions, and its token calls. `ctxOf(source)`
212
+ * gives each source the build's context (its repository and whether it's private, from the config
213
+ * as it is now). `resolve(token hash)` is the goal id or goal-event id it stands for, or null.
214
+ */
215
+ export function savedAsParsed(saved, { ctxOf, resolveToken = () => null, repoSlug = () => null }) {
216
+ // A pull request's repository comes back as the name the session's repository has now, when it
217
+ // was that one; otherwise it stays as saved, redacted, and matches nothing.
218
+ const slugBack = (x) => {
219
+ if (!x.repoHash) return x;
220
+ const slug = repoSlug();
221
+ const { repoHash, ...rest } = x;
222
+ return slug && repoNameHash(slug) === repoHash ? { ...rest, repo: slug } : rest;
223
+ };
224
+ const events = saved.events.map((e) => ({ ...e, refs: e.refs ?? [], facts: e.facts ?? {}, derived: e.derived ?? {}, inferred: e.inferred ?? [], missing: [...(e.missing ?? [])] }));
225
+ // Marked, in memory only, so assembly says nothing twice that a saved step already says.
226
+ for (const e of events) Object.defineProperty(e, '_saved', { value: true, enumerable: false });
227
+ const byId = new Map(events.map((e) => [e.id, e]));
228
+ // A gh command's pull requests, kept in memory beside its step for the reverse lookup, as the
229
+ // command's own text is for a step read from a log.
230
+ for (const c of saved.joins?.commandPrs ?? []) {
231
+ const e = byId.get(c.event);
232
+ if (!e) continue;
233
+ Object.defineProperty(e, '_prRefs', { value: (c.refs ?? []).map((r) => slugBack({ ...r })), enumerable: false });
234
+ Object.defineProperty(e, '_ranOutside', { value: c.ranOutside === true, enumerable: false });
235
+ }
236
+ const ev = (id) => byId.get(id) ?? null;
237
+ const withEvent = (list, map) => (list ?? []).map((x) => ({ ...map(x), event: ev(x.event) })).filter((x) => x.event);
238
+ const joins = saved.joins ?? {};
239
+ const out = [];
240
+ for (const s of saved.sources) {
241
+ const source = { key: s.key, tool: s.tool, role: s.role, sessionKey: saved.key, file: null, cwd: null, firstAt: s.firstAt, lastAt: s.lastAt, size: s.size, saved: true };
242
+ const ctx = ctxOf(source, s);
243
+ const isMain = s.role === 'session';
244
+ const own = events.filter((e) => e.source === s.key);
245
+ const ownIds = new Set(own.map((e) => e.id));
246
+ const onlyOwn = (list) => list.filter((x) => ownIds.has(x.event.id));
247
+ out.push({
248
+ source,
249
+ ctx,
250
+ result: {
251
+ events: own,
252
+ joins: {
253
+ calls: new Map(),
254
+ uuids: (joins.uuids ?? []).filter((u) => u.eventIds?.some((x) => String(x).startsWith(`${s.key}.`))),
255
+ sentMessages: onlyOwn(withEvent(joins.sentMessages, (m) => ({ msgId: m.msgId }))),
256
+ receivedMessages: onlyOwn(withEvent(joins.receivedMessages, (m) => ({ msgId: m.msgId }))),
257
+ chips: onlyOwn(withEvent(joins.chips, (c) => ({ digest: c.digest }))),
258
+ prs: onlyOwn(withEvent(joins.prs, (x) => slugBack({ ...x }))),
259
+ commits: onlyOwn(withEvent(joins.commits, (x) => slugBack({ ...x }))),
260
+ branches: onlyOwn(withEvent(joins.branches, (x) => ({ ...x }))),
261
+ watched: onlyOwn(withEvent(joins.watched, (x) => ({ token: resolveToken(x.token), where: x.where }))).filter((x) => x.token != null),
262
+ firstPromptDigest: isMain ? joins.firstPromptDigest ?? null : null,
263
+ firstPromptEvent: isMain ? joins.firstPromptEvent ?? null : null,
264
+ programStart: null,
265
+ hookLaunches: [],
266
+ },
267
+ coverage: new Map(Array.isArray(s.coverage) ? s.coverage : []),
268
+ anomalies: Array.isArray(s.anomalies) ? s.anomalies : [],
269
+ copyStubs: (joins.copies ?? []).filter((c) => c.source === s.key).map((c) => ({ id: c.id, kind: c.kind, refs: c.refs ?? [], facts: {}, derived: {}, inferred: [], missing: [] })),
270
+ stats: { records: s.records ?? 0, unparsable: s.unparsable ?? 0, oversized: s.oversized ?? 0, firstAt: s.firstAt, lastAt: s.lastAt, models: s.models ?? [], titles: [], entrypoint: s.entrypoint ?? null, origin: s.origin ?? null },
271
+ usage: (saved.usage ?? []).filter((c) => c.source === s.key).map((c) => ({ id: c.id, agent: c.agent, t: c.t, lines: c.lines ?? [], input: c.input, cacheWrite: c.cacheWrite, cacheRead: c.cacheRead, output: c.output, ...(c.ttl ? { ttl: c.ttl } : {}), hashed: true })),
272
+ },
273
+ });
274
+ }
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * After assembly: each saved session's own agents and the links its own records made come back as
280
+ * saved, replacing what assembly could make without the joins behind them; anomalies about its
281
+ * steps are left out (they weren't saved). Links between sessions that assembly made (a resumed
282
+ * copy, a message delivered, a hand-off) stay.
283
+ */
284
+ export function restoreSaved(a, savedList) {
285
+ if (!savedList.length) return;
286
+ const keys = new Set(savedList.map((s) => s.key));
287
+ const savedEvents = new Set(savedList.flatMap((s) => s.events.map((e) => e.id)));
288
+ const savedSources = new Set(savedList.flatMap((s) => s.sources.map((x) => x.key)));
289
+ for (const [k, agent] of a.agents) if (keys.has(agent.session)) a.agents.delete(k);
290
+ for (const s of savedList) for (const agent of s.agents ?? []) a.agents.set(agent.key, { ...agent, missing: [...(agent.missing ?? [])] });
291
+ const isSaved = (v) => typeof v === 'string' && (savedEvents.has(v) || keys.has(v) || savedSources.has(v) || savedSources.has(v.replace(/:.*$/, '')));
292
+ const kept = a.links.filter((l) => !(OWN_LINKS.has(l.type) && (isSaved(l.from) || isSaved(l.to))));
293
+ const seen = new Set(kept.map((l) => `${l.type}\u0000${l.from}\u0000${l.to}`));
294
+ for (const s of savedList) {
295
+ for (const l of s.links ?? []) {
296
+ if (!OWN_LINKS.has(l.type) && !SHARED_LINKS.has(l.type)) continue;
297
+ const k = `${l.type}\u0000${l.from}\u0000${l.to}`;
298
+ if (seen.has(k)) continue;
299
+ seen.add(k);
300
+ kept.push(l);
301
+ }
302
+ }
303
+ a.links.length = 0;
304
+ a.links.push(...kept);
305
+ const keptAnomalies = a.anomalies.filter((x) => !savedEvents.has(x.event));
306
+ a.anomalies.length = 0;
307
+ a.anomalies.push(...keptAnomalies, ...savedList.flatMap((s) => s.anomalies ?? []));
308
+ for (const s of savedList) {
309
+ const sess = a.sessionsByKey.get(s.key);
310
+ if (!sess) continue;
311
+ if (s.record.launchedBy && !sess.launchedBy) sess.launchedBy = s.record.launchedBy;
312
+ if (s.record.startedFrom && !sess.startedFrom) sess.startedFrom = s.record.startedFrom;
313
+ sess.missing = [...(s.record.missing ?? [])];
314
+ }
315
+ }
@@ -22,8 +22,19 @@ const I = EVIDENCE.INFERRED;
22
22
 
23
23
  const metric = (value, evidence, extra = {}) => ({ value, evidence, ...extra });
24
24
 
25
+ const QUARTER_HOUR = 15 * 60 * 1000;
26
+ const dayCache = new Map();
25
27
  export function localDay(t, tz) {
26
- return localDateInTimezone(new Date(t), tz).toISOString().slice(0, 10);
28
+ // Every timezone's offset, and every change of it, falls on a quarter hour, so one quarter hour
29
+ // never spans two local days: the day is worked out once per quarter hour and zone.
30
+ const key = `${tz}\u0000${Math.floor(t / QUARTER_HOUR)}`;
31
+ let day = dayCache.get(key);
32
+ if (day === undefined) {
33
+ day = localDateInTimezone(new Date(t), tz).toISOString().slice(0, 10);
34
+ if (dayCache.size > 200_000) dayCache.clear();
35
+ dayCache.set(key, day);
36
+ }
37
+ return day;
27
38
  }
28
39
 
29
40
  function hasInference(e, value) {
@@ -1,4 +1,4 @@
1
- // lib/view/word-index.mjs: "search everywhere" for `honestweek view`.
1
+ // lib/replay/word-index.mjs: "search everywhere", for `honestweek view`'s Find page and the `find` command.
2
2
  //
3
3
  // It reads the prompts and titles of every session in the window, from the same log
4
4
  // folders and window the history was built from, display-only repositories and folders
@@ -16,8 +16,8 @@
16
16
  // run's starting instruction. A child thread is skipped whole: its user messages are its
17
17
  // parent agent's hand-offs.
18
18
 
19
- import { readJsonlRecords, tryParse } from '../replay/jsonl.mjs';
20
- import { enumerateClaudeSources, enumerateCodexSources } from '../replay/sources.mjs';
19
+ import { readJsonlRecords, tryParse } from './jsonl.mjs';
20
+ import { enumerateClaudeSources, enumerateCodexSources } from './sources.mjs';
21
21
  import { createCodexTurnReader, isCodexExecSession } from '../codex-records.mjs';
22
22
 
23
23
  /** Harness text that arrives as a user record but isn't something a person typed. */
@@ -0,0 +1,122 @@
1
+ // lib/replay/words.mjs: phrase search over a built work history, for `honestweek view`'s Find
2
+ // page and the `find` command alike, so both read one implementation.
3
+ //
4
+ // Words match only sessions in configured repositories: a display-only or outside session never
5
+ // joins a lookup, whichever build answers. Goal titles and ids match when the history was built
6
+ // with a goal record. Each match says how it's known: words in a prompt, a title or a branch are
7
+ // recorded there; ranking prompts by how many of the words they share is a named rule's reading.
8
+ // Nothing here redacts: the history's strings already passed its redactor, and each caller shapes
9
+ // and scrubs its own answer. "Search everywhere", which reads every session's prompts again from
10
+ // the logs, is lib/replay/word-index.mjs.
11
+ //
12
+ // Zero runtime dependencies: Node built-ins only.
13
+
14
+ /** The rules behind word matches, beside the engine's own. Their ids keep the `view.` prefix
15
+ * they were first published under. */
16
+ export const WORD_RULES = Object.freeze({
17
+ 'view.shared-words': 'A prompt shares some of the words you typed. The score counts how many of your words appear in it as whole words; sharing words doesn\'t mean it was about the same thing.',
18
+ 'view.similar-prompt': 'Two prompts share a large part of their words (the shared words divided by all the words either uses). Similar wording doesn\'t mean similar work.',
19
+ });
20
+
21
+ /** The most prompts one word search returns, and the most a "similar prompts" answer does. */
22
+ export const MAX_PROMPTS = 20;
23
+ export const MAX_SIMILAR = 10;
24
+ /** The least share of words two prompts need to count as similar. */
25
+ export const SIMILAR_MIN = 0.2;
26
+
27
+ const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'can', 'do', 'for', 'from', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me', 'my', 'no', 'not', 'of', 'on', 'or', 'so', 'that', 'the', 'then', 'this', 'to', 'up', 'we', 'with', 'you', 'your', 'i', 'redacted']);
28
+ const MARKERS = /\[redacted:[a-z]+\]/g;
29
+
30
+ /** Lowercased words of a text, its redaction markers taken out first. */
31
+ export function wordsOf(text) {
32
+ return (String(text ?? '').replace(MARKERS, ' ').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_'-]*/gu) ?? []).filter((w) => w.length >= 2 && !STOP.has(w));
33
+ }
34
+
35
+ const PR_RE = /^(?:(?:[\w.-]+\/)?[\w.-]+#|pr\s*#?\s*|pull\/|#)(\d+)$/i;
36
+ const PR_URL = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/i;
37
+
38
+ /**
39
+ * Whether `text` reads as a reference (a pull request, a commit, a file or a branch) rather than
40
+ * words: the Find page's rule (isRef in lib/view/assets/search.js, which keeps its own copy),
41
+ * plus the `pr:`, `pull:`, `commit:` and `path:` prefixes the find command names, so a prefix
42
+ * always makes it a reference.
43
+ */
44
+ export function isReference(text) {
45
+ const q = String(text ?? '').trim();
46
+ if (/^(pr|pull|commit|path):/i.test(q)) return true;
47
+ return PR_RE.test(q) || PR_URL.test(q) || /^[0-9a-f]{7,40}$/i.test(q) || /^(file|branch):/i.test(q) || (!/\s/.test(q) && (/[\\/]/.test(q) || /^[\w.-]+\.[a-z0-9]{1,6}$/i.test(q)));
48
+ }
49
+
50
+ /** The prompts a word search reads: those with text, in sessions in configured repositories. */
51
+ export function readablePrompts(h) {
52
+ const readable = new Set(h.sessions.filter((s) => s.private === false).map((s) => s.key));
53
+ return h.events.filter((e) => readable.has(e.session) && e.kind === 'prompt' && typeof e.facts?.text === 'string' && e.facts.text);
54
+ }
55
+
56
+ /**
57
+ * matchWords(h, text) -> { want, goals, sessions, branches, prompts }
58
+ * want the query's words (wordsOf), each once
59
+ * goals [{ index, matchedIn: 'title' | 'id' }], an index into h.goals, for a goal whose
60
+ * title or id holds every word
61
+ * sessions [key] of configured sessions whose title holds every word
62
+ * branches [{ branch, sessions: [{ session, event }] }] for each branch name (pushed, checked
63
+ * out, or the session's mode) holding the text, with each session's first such step
64
+ * prompts [{ event, shared }] up to MAX_PROMPTS prompts sharing any of the words, most shared
65
+ * first, then newest; `event` is the history's own event
66
+ */
67
+ export function matchWords(h, text) {
68
+ const q = String(text ?? '');
69
+ const want = [...new Set(wordsOf(q))];
70
+ const low = q.toLowerCase();
71
+ const has = (t) => want.length > 0 && want.every((w) => String(t ?? '').toLowerCase().includes(w));
72
+ const goals = (h.goals ?? []).flatMap((g, index) => {
73
+ const inTitle = has(g.title);
74
+ return inTitle || has(g.id) ? [{ index, matchedIn: inTitle ? 'title' : 'id' }] : [];
75
+ });
76
+ const sessions = h.sessions.filter((s) => s.private === false && s.title && has(s.title)).map((s) => s.key);
77
+ const readable = new Set(h.sessions.filter((s) => s.private === false).map((s) => s.key));
78
+ const branchMap = new Map();
79
+ for (const e of h.events) {
80
+ if (!readable.has(e.session)) continue;
81
+ const f = e.facts ?? {};
82
+ for (const b of [f.git?.push?.branch, f.git?.branch?.ref, e.kind === 'mode' ? f.branch : null]) {
83
+ if (typeof b !== 'string' || !b || !b.toLowerCase().includes(low)) continue;
84
+ if (!branchMap.has(b)) branchMap.set(b, new Map());
85
+ if (!branchMap.get(b).has(e.session)) branchMap.get(b).set(e.session, e.id);
86
+ }
87
+ }
88
+ const branches = [...branchMap].map(([branch, bySession]) => ({ branch, sessions: [...bySession].map(([session, event]) => ({ session, event })) }));
89
+ const prompts = readablePrompts(h)
90
+ .map((e) => {
91
+ const theirs = new Set(wordsOf(e.facts.text));
92
+ return { event: e, shared: want.filter((w) => theirs.has(w)).length };
93
+ })
94
+ .filter((x) => x.shared > 0)
95
+ .sort((a, b) => b.shared - a.shared || b.event.t - a.event.t)
96
+ .slice(0, MAX_PROMPTS);
97
+ return { want, goals, sessions, branches, prompts };
98
+ }
99
+
100
+ /**
101
+ * similarPrompts(h, eventId) -> null when no readable prompt has that id, else
102
+ * [{ event, score }]: up to MAX_SIMILAR other prompts sharing at least SIMILAR_MIN of their
103
+ * words with it (shared words over all the words either uses), most similar first, then newest.
104
+ */
105
+ export function similarPrompts(h, eventId) {
106
+ const prompts = readablePrompts(h);
107
+ const base = prompts.find((e) => e.id === eventId);
108
+ if (!base) return null;
109
+ const mine = new Set(wordsOf(base.facts.text));
110
+ return prompts
111
+ .filter((e) => e.id !== base.id)
112
+ .map((e) => {
113
+ const theirs = new Set(wordsOf(e.facts.text));
114
+ let shared = 0;
115
+ for (const w of theirs) if (mine.has(w)) shared += 1;
116
+ const union = mine.size + theirs.size - shared;
117
+ return { event: e, score: union ? shared / union : 0 };
118
+ })
119
+ .filter((x) => x.score >= SIMILAR_MIN)
120
+ .sort((a, b) => b.score - a.score || b.event.t - a.event.t)
121
+ .slice(0, MAX_SIMILAR);
122
+ }
@@ -6,7 +6,7 @@
6
6
  import { existsSync, realpathSync } from 'node:fs';
7
7
  import { basename, dirname, join, resolve, sep } from 'node:path';
8
8
 
9
- import { gitDirOf, resolveCommonDir } from './worktrees.mjs';
9
+ import { gitDirOf, linkedWorkTrees, resolveCommonDir } from './worktrees.mjs';
10
10
 
11
11
  const samePath = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
12
12
 
@@ -45,36 +45,104 @@ const isUnder = (child, parent) => child.length > parent.length && child.startsW
45
45
  * or null. Git reading a repository reads the history of every folder in it, so a display
46
46
  * folder inside a featured or reference repository (or inside another worktree of it) would be
47
47
  * read after all (AGENTS.md invariant 4); a read repository inside a display folder is refused
48
- * too, since the person said that whole folder is display-only. Folders are compared by their
49
- * real paths, so a junction, a symlink or another case on Windows can't hide the overlap. The
50
- * same folder twice is left to checkSameRepos. `repos` are { path, label?, role }, paths resolved.
48
+ * too, since the person said that whole folder is display-only, and so is one with a worktree
49
+ * (or its main checkout) inside a display folder, since git reads the work done there with the
50
+ * rest of the repository. Folders are compared by their real paths, so a junction, a symlink or
51
+ * another case on Windows can't hide the overlap. The same folder twice is left to
52
+ * checkSameRepos. `repos` are { path, label?, role }, paths resolved.
51
53
  */
52
54
  export function checkNestedRoles(repos) {
53
55
  const keyed = repos.map((r) => ({ ...r, key: folderKey(r.path), name: r.label || basename(r.path) }));
54
56
  const display = keyed.filter((r) => r.role === 'display');
55
- const read = keyed.filter((r) => r.role !== 'display');
57
+ if (display.length === 0) return null;
58
+ const read = keyed.filter((r) => r.role !== 'display').map((r) => ({ ...r, trees: workTreesOf(r.path) }));
56
59
  for (const d of display) {
57
60
  const walked = repoKey(d.path, { walk: true });
58
61
  for (const r of read) {
59
62
  if (isUnder(d.key, r.key) || (walked && !repoKey(d.path) && walked === repoKey(r.path))) return `${d.name} is display-only but sits inside ${r.name}, which git reads, so its history would be read too. Move it out of ${r.name}, or mark ${r.name} display as well.`;
60
63
  if (isUnder(r.key, d.key)) return `${r.name} is read by git but sits inside ${d.name}, which is display-only. Mark ${r.name} display as well, or remove one of them.`;
64
+ const wt = r.trees.find((t) => isUnder(t.key, d.key));
65
+ if (wt) return worktreeInside(r.name, wt, d.name);
61
66
  }
62
67
  }
63
68
  return null;
64
69
  }
65
70
 
71
+ /** The refusal for a read repository with a checkout inside a display folder. A worktree whose
72
+ * folder is gone still counts, since git lists it until it's pruned and sessions run there would
73
+ * count as the repository's. Each way out is one git can follow: a hand move needs a repair
74
+ * before a prune would drop the moved worktree's records, and a locked worktree needs an unlock. */
75
+ function worktreeInside(name, wt, display) {
76
+ const tree = basename(wt.path);
77
+ if (wt.main) return `${name} is read by git but its main checkout, ${tree}, sits inside ${display}, which is display-only, so the work done there would be read too. Move it out of ${display}, or mark ${name} display as well.`;
78
+ if (!existsSync(wt.path) && wt.locked) return `${name} is read by git, which lists a locked worktree of it, ${tree}, inside ${display}, which is display-only, though that folder can't be found. If you moved it by hand, run git worktree repair with its new folder in ${name}; if its drive isn't connected, reconnect it and run git worktree unlock and git worktree move there; if it's gone for good, run git worktree unlock and git worktree prune there; or mark ${name} display as well.`;
79
+ if (!existsSync(wt.path)) return `${name} is read by git, which still lists a worktree of it, ${tree}, inside ${display}, which is display-only, though that folder is gone. If you moved it by hand, run git worktree repair with its new folder in ${name}; if it's gone for good, run git worktree prune there; or mark ${name} display as well.`;
80
+ const unlock = wt.locked ? 'git worktree unlock and ' : '';
81
+ return `${name} is read by git but has a worktree, ${tree}, inside ${display}, which is display-only, so the work done there would be read too. Move that worktree out of ${display} with ${unlock}git worktree move, or mark ${name} display as well.`;
82
+ }
83
+
84
+ /** Whether git running in folder `p` would reach a display-only folder too: `p` holds one, or
85
+ * sits inside one, or is a plain subfolder of a checkout that holds one, since git there reads
86
+ * that whole checkout (checkNestedRoles). Never runs git. */
87
+ export function nestsDisplay(displayPaths) {
88
+ const display = displayPaths.map((d) => ({ path: d, role: 'display' }));
89
+ const nested = (q) => checkNestedRoles([{ path: q, role: 'reference' }, ...display]) !== null;
90
+ return (p) => {
91
+ const top = checkoutOf(p);
92
+ return nested(p) || (top !== null && nested(top));
93
+ };
94
+ }
95
+
96
+ /** Whether git can't be asked about folder `p` without reaching a display-only folder: `p` is,
97
+ * or sits in, one of `displayPaths` or another worktree of one (displayTest), or git there would
98
+ * reach one (nestsDisplay). Settings and discover both use it, so they skip git in the same
99
+ * places. Never runs git. */
100
+ export function reachesDisplay(displayPaths) {
101
+ const isDisplay = displayTest(displayPaths);
102
+ const nests = nestsDisplay(displayPaths);
103
+ return (p) => isDisplay(p, { walk: true }) || nests(p);
104
+ }
105
+
106
+ /** The nearest folder at or above `p` that holds a .git, or null outside a checkout. It walks up
107
+ * from `p`'s real path, as git does, so a junction or symlink into a checkout finds that
108
+ * checkout, not the one its name sits in. Never runs git. */
109
+ export function checkoutOf(p) {
110
+ let start;
111
+ try {
112
+ start = realpathSync.native(p);
113
+ } catch {
114
+ start = resolve(p);
115
+ }
116
+ for (let d = start; ; d = dirname(d)) {
117
+ if (existsSync(join(d, '.git'))) return d;
118
+ if (dirname(d) === d) return null;
119
+ }
120
+ }
121
+
66
122
  /** The git repository a folder belongs to, as its shared git folder (the one every worktree
67
123
  * of it points at), read from git's files on disk, never by running git. With `walk`, a
68
124
  * subfolder is read from the checkout that holds it. Null outside a repository. */
69
125
  export function repoKey(p, { walk = false } = {}) {
70
- for (let d = resolve(p); ; ) {
71
- // A worktree points at its repository's shared folder; a main checkout uses its own git
72
- // folder, which `git init --separate-git-dir` puts elsewhere and names in a .git file.
73
- if (existsSync(join(d, '.git'))) return folderKey(resolveCommonDir(d) ?? gitDirOf(d) ?? join(d, '.git'));
74
- const up = dirname(d);
75
- if (!walk || up === d) return null;
76
- d = up;
77
- }
126
+ const d = walk ? checkoutOf(p) : existsSync(join(resolve(p), '.git')) ? resolve(p) : null;
127
+ return d === null ? null : folderKey(sharedGitDir(d));
128
+ }
129
+
130
+ /** A worktree points at its repository's shared folder; a main checkout uses its own git
131
+ * folder, which `git init --separate-git-dir` puts elsewhere and names in a .git file. */
132
+ const sharedGitDir = (checkout) => resolveCommonDir(checkout) ?? gitDirOf(checkout) ?? join(checkout, '.git');
133
+
134
+ /** Every folder the repository holding `p` is checked out in, as { path, key, main, locked }: its main
135
+ * checkout and each linked worktree git lists in its shared folder. Read from git's files on
136
+ * disk, never by running git. Empty for a folder that isn't there or isn't in a repository. */
137
+ function workTreesOf(p) {
138
+ if (!existsSync(p)) return [];
139
+ const checkout = checkoutOf(p);
140
+ if (checkout === null) return [];
141
+ const common = sharedGitDir(checkout);
142
+ const trees = linkedWorkTrees(common, { withLocks: true }).map((t) => ({ ...t, main: false }));
143
+ // A main checkout keeps its git folder as `.git` inside it; a bare repository has no checkout.
144
+ if (basename(common) === '.git') trees.push({ path: dirname(common), main: true, locked: false });
145
+ return trees.map((t) => ({ ...t, key: folderKey(t.path) }));
78
146
  }
79
147
 
80
148
  /**