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
package/lib/view/data.mjs CHANGED
@@ -22,20 +22,23 @@ import { randomBytes } from 'node:crypto';
22
22
  import { readFileSync } from 'node:fs';
23
23
 
24
24
  import { buildWorkHistory } from '../replay/index.mjs';
25
+ import { logIdHash } from '../replay/saved-sessions.mjs';
25
26
  import { normalizeGoalRecord } from '../replay/goals.mjs';
26
27
  import { letterHash } from '../replay/ids.mjs';
27
28
  import { createRedactor, createSecretsOnlyRedactor } from '../redact.mjs';
28
29
  import { createLeakCounter } from './leaks.mjs';
29
30
  import { buildTimeline } from '../replay/timeline.mjs';
30
- import { localDay } from '../replay/views.mjs';
31
+ import { describe, localDay } from '../replay/views.mjs';
31
32
  import { privateWordCount, privateWordsNote } from '../private-words.mjs';
32
33
  import { eventRow, exportThread, frameAt, frameMoments, sessionGroup, timeNoteOf, whoOf } from './replay-export.mjs';
33
- import { buildWordIndex, createWordSearch, cutRedacted } from './word-index.mjs';
34
+ import { buildWordIndex, createWordSearch, cutRedacted } from '../replay/word-index.mjs';
35
+ import { matchWords, similarPrompts, WORD_RULES, wordsOf } from '../replay/words.mjs';
34
36
  import { createProblemsRoute } from './problems-route.mjs';
35
37
  import { earlierWindow, runProblems, trendCounts } from '../problems/index.mjs';
36
38
  import { INSIGHTS_TEXT } from './insights.mjs';
37
39
  import { sessionFacts, windowTotals } from './facts.mjs';
38
40
  import { sessionText } from './codex-judge.mjs';
41
+ import { suggestWords } from './suggest-words.mjs';
39
42
 
40
43
  /** The most moments a goal page samples per member session. */
41
44
  const GOAL_FRAMES = 600;
@@ -49,11 +52,9 @@ export const SESSIONS_PAGE = 20;
49
52
  /** The day a session with no recorded time is listed under, after every dated day. */
50
53
  export const NO_DAY = 'none';
51
54
 
52
- /** The rules behind results the page computes itself, beside the engine's own. */
53
- export const VIEW_RULES = Object.freeze({
54
- '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.',
55
- '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.',
56
- });
55
+ /** The rules behind results the page computes itself, beside the engine's own: the phrase
56
+ * search's (lib/replay/words.mjs). */
57
+ export const VIEW_RULES = WORD_RULES;
57
58
 
58
59
  /** The five evidence words every page uses, in plain words. */
59
60
  export const EVIDENCE_KEY = Object.freeze({
@@ -99,8 +100,6 @@ export function demoNotice(run) {
99
100
  });
100
101
  }
101
102
 
102
- 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']);
103
- const MARKERS = /\[redacted:[a-z]+\]/g;
104
103
 
105
104
  /** A goal's key: a letter hash of its raw id, so it never shows the id and the redactor
106
105
  * never alters it. */
@@ -108,10 +107,8 @@ export function goalKey(rawId) {
108
107
  return `g${letterHash(`goal\u0000${String(rawId)}`, 15)}`;
109
108
  }
110
109
 
111
- /** Lowercased words of a text, its redaction markers taken out first. */
112
- export function wordsOf(text) {
113
- return (String(text ?? '').replace(MARKERS, ' ').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_'-]*/gu) ?? []).filter((w) => w.length >= 2 && !STOP.has(w));
114
- }
110
+ /** Lowercased words of a text (the engine's phrase search, lib/replay/words.mjs). */
111
+ export { wordsOf };
115
112
 
116
113
  /** A small least-recently-used map: get() refreshes an entry, set() drops the oldest
117
114
  * once it holds more than `size`. */
@@ -181,6 +178,38 @@ function firstWords(text, n = 8) {
181
178
  }
182
179
 
183
180
  const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + 1;
181
+
182
+ /** The shortest start of an id that `matchSession` reads as that id. */
183
+ export const SESSION_PREFIX_MIN = 8;
184
+
185
+ /**
186
+ * matchSession(h, text) -> { key, group, checked } | { matches: [key...] } | { none: true }
187
+ * A session of history `h` by its key, by the id in its own log (a Claude Code session's id, a
188
+ * Codex thread id, which the engine keeps in memory only), or by the start of either,
189
+ * SESSION_PREFIX_MIN characters or more. A start several sessions share returns them all.
190
+ * `checked` is the Problems checks' own rule: a session in a configured repository.
191
+ */
192
+ export function matchSession(h, text) {
193
+ const want = String(text ?? '').trim().toLowerCase();
194
+ if (!want || want.length > 200) return { none: true };
195
+ const ownIds = [...(h.claudeIds ?? new Map()), ...(h.codexIds ?? new Map())];
196
+ const byKey = new Map(h.sessions.map((s) => [s.key, s]));
197
+ const found = (key) => {
198
+ const s = byKey.get(key);
199
+ return s ? { key, group: sessionGroup(s), checked: sessionGroup(s) === 'configured' } : null;
200
+ };
201
+ // A session that came back from saved results keeps its log's id only as a hash: a full id finds it.
202
+ const savedId = h.savedLogIds?.size ? [...h.savedLogIds].find(([, x]) => x === logIdHash(want))?.[0] : undefined;
203
+ const exact = found(want) ?? found(ownIds.find(([, id]) => id === want)?.[0]) ?? found(savedId);
204
+ if (exact) return exact;
205
+ if (want.length < SESSION_PREFIX_MIN) return { none: true };
206
+ const keys = new Set([...byKey.keys()].filter((k) => k.startsWith(want)));
207
+ for (const [k, id] of ownIds) if (id.startsWith(want) && byKey.has(k)) keys.add(k);
208
+ const list = [...keys].sort();
209
+ if (list.length === 1) return found(list[0]);
210
+ return list.length ? { matches: list } : { none: true };
211
+ }
212
+
184
213
  const ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
185
214
  const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
186
215
  const OFFSET_RE = /^\d{1,6}$/;
@@ -263,7 +292,7 @@ function goalSplit(members) {
263
292
  // The private-word count and note live in lib/private-words.mjs, shared with init.
264
293
  export { privateWordCount, privateWordsNote };
265
294
 
266
- export function createViewData({ config, roots, from, to, timezone, goalRecord = null, demo = false, selfTest = false, command: ranAs = 'honestweek', buildHistory = buildWorkHistory, cacheSize = REPLAY_CACHE_SIZE, now = () => Date.now(), windowNote = null, insights = null, codexJudge = null, partial = null, onProgress = null, earlierCheck = null, privateCheck = null, earlierProgressive = false, queries = null }) {
295
+ export function createViewData({ config, roots, from, to, timezone, goalRecord = null, demo = false, selfTest = false, command: ranAs = 'honestweek', buildHistory = buildWorkHistory, cacheSize = REPLAY_CACHE_SIZE, now = () => Date.now(), windowNote = null, insights = null, codexJudge = null, settingsOpen = true, partial = null, onProgress = null, earlierCheck = null, privateCheck = null, earlierProgressive = false, queries = null, onChecked = null, savedLoad = null, savedCounts = null }) {
267
296
  const wordCount = privateWordCount(config);
268
297
  const fullRedactor = createRedactor(config);
269
298
  // How the person ran honestweek, for the commands a page names. It goes through the
@@ -292,7 +321,8 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
292
321
  const builds = { redacted: slot(), private: slot() };
293
322
  const words = { state: 'waiting', ms: null, error: null, search: null, sessions: 0 };
294
323
 
295
- // Set when Settings replaces this data: a build still reading stops before any git read.
324
+ // Set when Settings replaces this data or the page stops: a build still reading, this
325
+ // window's or the week before's, stops at its next check and runs no more git.
296
326
  const signal = { aborted: false };
297
327
  const reason = (err) => cutRedacted(fullRedactor.redact(String(err?.message ?? err ?? 'unknown error').split('\n')[0]), 200);
298
328
 
@@ -308,6 +338,17 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
308
338
  // redacted text rather than skeletons; an engine without that option ignores it.
309
339
  // The redacted build also records token counts and keeps raw text in memory for the
310
340
  // problem checks, which run on it alone; an engine without those options ignores them.
341
+ // Saved sessions whose logs are gone (lib/saved/history.mjs) join both builds, redacted as
342
+ // saved: private text is never saved, so the private build shows them redacted too. The
343
+ // redacted build is the one saved from, so it keeps what the saver exports.
344
+ const savedNow = savedSessions();
345
+ if (savedNow) {
346
+ options.saved = savedNow.sessions;
347
+ // Sessions whose logs haven't changed since they were saved come back unread, in the
348
+ // redacted build only: the private build reads their logs for the text the saved copy hid.
349
+ if (mode === 'redacted' && savedNow.reuse?.size) options.reuse = savedNow.reuse;
350
+ }
351
+ if (mode === 'redacted' && typeof onChecked === 'function') options.saving = true;
311
352
  if (mode === 'private') options.privateText = true;
312
353
  else Object.assign(options, { hiddenSessions: 'redacted', usage: true, keepRaw: true, ...(typeof onProgress === 'function' ? { onProgress } : {}) });
313
354
  // A second copy of a large window may not fit beside the first: it isn't built then, and the
@@ -329,6 +370,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
329
370
  b.state = 'ready';
330
371
  b.ms = now() - b.startedAt;
331
372
  if (mode === 'redacted') startWords(h);
373
+ if (mode === 'redacted' && !partial && typeof onChecked === 'function') checkedReady(b);
332
374
  },
333
375
  (err) => {
334
376
  b.state = 'failed';
@@ -339,6 +381,35 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
339
381
  return b.promise;
340
382
  }
341
383
 
384
+ // With "saveResults" on (lib/saved/), the whole window's check results go to `onChecked` once
385
+ // its redacted build is ready: the same run of the checks every Problems answer reads, so they
386
+ // run once. A first day shown while the rest loads (`partial`) is never saved. A failure to
387
+ // save is the saver's to report; the page goes on.
388
+ function checkedReady(b) {
389
+ setTimeout(() => {
390
+ if (signal.aborted || builds.redacted !== b) return;
391
+ try {
392
+ const c = problemsRoute.checked(b.h, b.startedAt);
393
+ if (c) onChecked({ h: b.h, builtT: b.startedAt, result: c.result, keys: c.keys, describeStep: (id) => (b.eventsById.has(id) ? describe(b.eventsById.get(id)) : null) });
394
+ } catch {
395
+ /* the saver records its own failure */
396
+ }
397
+ }, 0);
398
+ }
399
+
400
+ // Read once, for both builds and the checks.
401
+ let savedRead;
402
+ function savedSessions() {
403
+ if (savedRead === undefined) {
404
+ try {
405
+ savedRead = typeof savedLoad === 'function' ? savedLoad({ from, to, timezone }) ?? null : null;
406
+ } catch {
407
+ savedRead = null;
408
+ }
409
+ }
410
+ return savedRead;
411
+ }
412
+
342
413
  function startWords(h) {
343
414
  words.state = 'building';
344
415
  const t0 = now();
@@ -423,7 +494,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
423
494
  // config lists any private words (the demo always has its made-up one).
424
495
  command,
425
496
  // One short line on every page; the rest of the note sits behind its "?".
426
- privateWords: { count: wordCount, note: !demo && wordCount === 0 ? PRIVATE_WORDS_SHORT : null, more: !demo && wordCount === 0 ? privateWordsNote(command, { restart: false }) : null },
497
+ privateWords: { count: wordCount, note: !demo && wordCount === 0 ? PRIVATE_WORDS_SHORT : null, more: !demo && wordCount === 0 ? privateWordsNote(command, { restart: false, settings: settingsOpen }) : null },
427
498
  selfTest,
428
499
  evidenceKey: EVIDENCE_KEY,
429
500
  };
@@ -572,11 +643,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
572
643
  return launch ? { ...sb, launch } : sb;
573
644
  }
574
645
 
646
+ /** A session that came back as saved (lib/saved/history.mjs): when, by which version, and
647
+ * whether its log is gone. Nothing for any other session, so its answer is what it was. */
648
+ const savedOf = (s) => (s?.saved ? { saved: { at: s.saved.at ?? null, by: s.saved.by ?? null, log: s.saved.log ?? null } } : {});
575
649
  const sessionRow = (ctx, key) => {
576
650
  const s = ctx.sessionByKey.get(key);
577
651
  if (!s) return { session: key, thread: null, tool: null, repo: null, group: null, title: null, firstAt: null, lastAt: null, startedBy: null };
578
652
  const label = labelOf(ctx, s.key);
579
- return { session: s.key, thread: s.thread, tool: s.tool, repo: s.repo, group: sessionGroup(s), title: s.title, ...(label ? { label } : {}), firstAt: s.firstAt, lastAt: s.lastAt, startedBy: startedWith(ctx, s.key) };
653
+ return { session: s.key, thread: s.thread, tool: s.tool, repo: s.repo, group: sessionGroup(s), title: s.title, ...(label ? { label } : {}), firstAt: s.firstAt, lastAt: s.lastAt, startedBy: startedWith(ctx, s.key), ...savedOf(s) };
580
654
  };
581
655
 
582
656
  /** The goals a session is a member of, each with that session's place in the goal. */
@@ -776,73 +850,36 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
776
850
  return { event: e.id, ...sessionRow(ctx, e.session), t: e.t, at: e.at ?? null, text: cutRedacted(String(e.facts?.text ?? ''), 240), who: whoOf(e, new Map(ctx.h.agents.map((a) => [a.key, a]))), goals: goalsOfSession(ctx, e.session), ...extra };
777
851
  }
778
852
 
853
+ // Phrase search is the engine's (lib/replay/words.mjs): display-only and outside sessions never
854
+ // join a lookup, whichever build answers. These shape its matches into the page's rows.
779
855
  function wordSearch(ctx, params) {
780
- const h = ctx.h;
781
- // Display-only and outside sessions never join a lookup, whichever build answers.
782
- const prompts = readableEvents(ctx).filter((e) => e.kind === 'prompt' && typeof e.facts?.text === 'string' && e.facts.text);
783
856
  const similarTo = params.get('similar');
784
857
  if (similarTo) {
785
858
  if (!ID_RE.test(similarTo)) return json(400, { error: 'That prompt id is malformed.' });
786
- const base = prompts.find((e) => e.id === similarTo);
787
- if (!base) return json(404, { error: 'No prompt with that id in this window.' });
788
- const mine = new Set(wordsOf(base.facts.text));
789
- const similar = prompts
790
- .filter((e) => e.id !== base.id)
791
- .map((e) => {
792
- const theirs = new Set(wordsOf(e.facts.text));
793
- let shared = 0;
794
- for (const w of theirs) if (mine.has(w)) shared += 1;
795
- const union = mine.size + theirs.size - shared;
796
- return { e, score: union ? shared / union : 0 };
797
- })
798
- .filter((x) => x.score >= 0.2)
799
- .sort((a, b) => b.score - a.score || b.e.t - a.e.t)
800
- .slice(0, 10)
801
- .map(({ e, score }) => promptRow(ctx, e, { evidence: 'inferred', rule: 'view.similar-prompt', score: { value: Math.round(score * 100) / 100, evidence: 'inferred', rule: 'view.similar-prompt' } }));
802
- return { similarTo: base.id, similar, goals: [], sessions: [], branches: [], prompts: [], empty: similar.length ? null : 'No prompt in this window shares enough words with that one.', rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
859
+ const found = similarPrompts(ctx.h, similarTo);
860
+ if (!found) return json(404, { error: 'No prompt with that id in this window.' });
861
+ const similar = found.map(({ event: e, score }) => promptRow(ctx, e, { evidence: 'inferred', rule: 'view.similar-prompt', score: { value: Math.round(score * 100) / 100, evidence: 'inferred', rule: 'view.similar-prompt' } }));
862
+ return { similarTo, similar, goals: [], sessions: [], branches: [], prompts: [], empty: similar.length ? null : 'No prompt in this window shares enough words with that one.', rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
803
863
  }
804
864
  const query = readQuery(params, { goals: [], sessions: [], branches: [], prompts: [], similar: [] });
805
865
  if (query.answer) return query.answer;
806
- return withQuery(query, wordsFor(ctx, query.text, prompts));
866
+ return withQuery(query, wordsFor(ctx, query.text));
807
867
  }
808
868
 
809
- function wordsFor(ctx, q, prompts) {
869
+ function wordsFor(ctx, q) {
810
870
  const h = ctx.h;
811
- const want = [...new Set(wordsOf(q))];
812
- const low = q.toLowerCase();
813
- const has = (text) => want.length > 0 && want.every((w) => String(text ?? '').toLowerCase().includes(w));
871
+ const m = matchWords(h, q);
814
872
  const goals = record
815
- ? h.goals.flatMap((g, i) => {
816
- const inTitle = has(g.title);
817
- const inId = has(g.id);
818
- return inTitle || inId ? [{ key: keys[i], title: g.title, state: g.state, matchedIn: inTitle ? 'title' : 'id', evidence: 'recorded', members: memberCount(g.members) }] : [];
873
+ ? m.goals.map(({ index, matchedIn }) => {
874
+ const g = h.goals[index];
875
+ return { key: keys[index], title: g.title, state: g.state, matchedIn, evidence: 'recorded', members: memberCount(g.members) };
819
876
  })
820
877
  : [];
821
- const sessions = h.sessions.filter((s) => s.private === false && s.title && has(s.title)).map((s) => ({ ...sessionRow(ctx, s.key), evidence: 'recorded', goals: goalsOfSession(ctx, s.key) }));
822
- const branchMap = new Map();
823
- for (const e of readableEvents(ctx)) {
824
- const f = e.facts ?? {};
825
- for (const b of [f.git?.push?.branch, f.git?.branch?.ref, e.kind === 'mode' ? f.branch : null]) {
826
- if (typeof b !== 'string' || !b || !b.toLowerCase().includes(low)) continue;
827
- if (!branchMap.has(b)) branchMap.set(b, new Map());
828
- if (!branchMap.get(b).has(e.session)) branchMap.get(b).set(e.session, e.id);
829
- }
830
- }
831
- const branches = [...branchMap].map(([branch, bySession]) => ({ branch, evidence: 'recorded', sessions: [...bySession].map(([session, event]) => ({ ...sessionRow(ctx, session), event, evidence: 'recorded' })) }));
832
- const scored = prompts
833
- .map((e) => {
834
- const theirs = new Set(wordsOf(e.facts.text));
835
- const shared = want.filter((w) => theirs.has(w)).length;
836
- return { e, shared };
837
- })
838
- .filter((x) => x.shared > 0)
839
- .sort((a, b) => b.shared - a.shared || b.e.t - a.e.t)
840
- .slice(0, 20)
841
- .map(({ e, shared }) => {
842
- // The words are in the prompt's text, so the row is recorded; ranking it by how many
843
- // of them it shares is a named rule's reading, so the score is inferred.
844
- return promptRow(ctx, e, { evidence: 'recorded', rule: 'view.shared-words', matched: shared === want.length ? 'all your words' : 'some of your words', score: { value: shared, of: want.length, evidence: 'inferred', rule: 'view.shared-words' } });
845
- });
878
+ const sessions = m.sessions.map((key) => ({ ...sessionRow(ctx, key), evidence: 'recorded', goals: goalsOfSession(ctx, key) }));
879
+ const branches = m.branches.map(({ branch, sessions: bySession }) => ({ branch, evidence: 'recorded', sessions: bySession.map(({ session, event }) => ({ ...sessionRow(ctx, session), event, evidence: 'recorded' })) }));
880
+ // The words are in the prompt's text, so the row is recorded; ranking it by how many of them
881
+ // it shares is a named rule's reading, so the score is inferred.
882
+ const scored = m.prompts.map(({ event: e, shared }) => promptRow(ctx, e, { evidence: 'recorded', rule: 'view.shared-words', matched: shared === m.want.length ? 'all your words' : 'some of your words', score: { value: shared, of: m.want.length, evidence: 'inferred', rule: 'view.shared-words' } }));
846
883
  const nothing = !goals.length && !sessions.length && !branches.length && !scored.length;
847
884
  return { goals, sessions, branches, prompts: scored, similar: [], empty: nothing ? emptyText() : null, rules: VIEW_RULES, evidenceKey: EVIDENCE_KEY };
848
885
  }
@@ -928,6 +965,16 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
928
965
  if (launch) data = { ...data, launch };
929
966
  ctx.b.replays.set(cacheKey, data);
930
967
  }
968
+ // `at` (milliseconds): the thread's state at exactly that moment, read from its timeline
969
+ // rather than the sampled frames, which change only at a step's start. `replay --at` asks
970
+ // for it; the page doesn't, so its answer stays as it was.
971
+ const at = params.get('at');
972
+ if (at !== null) {
973
+ const t = Number(at);
974
+ if (at.trim() === '' || !Number.isFinite(t)) return json(400, { error: 'That moment is malformed.' });
975
+ const tl = h.threadTimeline(threadId);
976
+ return { ...data, atFrame: tl ? frameAt(tl, t) : null };
977
+ }
931
978
  return data;
932
979
  }
933
980
 
@@ -959,6 +1006,7 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
959
1006
  git: !!r.ref?.sha,
960
1007
  verified: r.verified,
961
1008
  note: r.note,
1009
+ ...(r.saved === true ? { saved: true } : {}),
962
1010
  record: r.record == null ? null : joinLinesWhereNeeded(r.record, ctx.redact),
963
1011
  }));
964
1012
  const result = reads.find((x) => x.part === 'result');
@@ -1045,7 +1093,8 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
1045
1093
  // ---- problems: the checks run once on the redacted build; each answer is drawn for its own --
1046
1094
  // The long-session check runs only with a limit set in Settings (config.longSessionTokens).
1047
1095
  const longSession = Number.isInteger(config?.longSessionTokens) ? config.longSessionTokens : null;
1048
- const problemsRoute = createProblemsRoute({ now, longSessionTokens: longSession });
1096
+ // A saved session's findings are the ones saved with it (runProblems' `saved`).
1097
+ const problemsRoute = createProblemsRoute({ now, longSessionTokens: longSession, run: (h, o) => runProblems(h, { ...o, saved: savedSessions()?.findings ?? null, savedStatus: savedSessions()?.statuses ?? null }) });
1049
1098
  /** The sessions a goal joins, by the redacted build's membership (the same in both builds). */
1050
1099
  function membersOfGoal(key) {
1051
1100
  const i = indexOfKey.get(String(key ?? ''));
@@ -1082,6 +1131,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
1082
1131
  return { first: null, whole: Promise.resolve(value), done };
1083
1132
  };
1084
1133
  if (!w) return settled({ window: null, error: 'no window' });
1134
+ // With every day of it saved, its counts come from the saved check results, and nothing is read.
1135
+ let fromSaved = null;
1136
+ try {
1137
+ fromSaved = typeof savedCounts === 'function' ? savedCounts({ from: w.from, to: w.to, timezone }) : null;
1138
+ } catch {
1139
+ fromSaved = null;
1140
+ }
1141
+ if (fromSaved) return settled({ window: w, ...fromSaved, saved: true, ms: 0 });
1085
1142
  // Past the log limit, or more than memory holds beside this window: not read, or only its
1086
1143
  // newest days, and the page says which in a few words.
1087
1144
  const check = typeof earlierCheck === 'function' ? earlierCheck(w) : null;
@@ -1089,11 +1146,18 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
1089
1146
  const wholeFrom = check?.from ?? w.from;
1090
1147
  const count = (a, b) => {
1091
1148
  const t0 = now();
1149
+ // The days before count their saved sessions whose logs are gone too, as this window does.
1150
+ let back = null;
1151
+ try {
1152
+ back = typeof savedLoad === 'function' ? savedLoad({ from: a, to: b, timezone }) ?? null : null;
1153
+ } catch {
1154
+ back = null;
1155
+ }
1092
1156
  return Promise.resolve()
1093
- .then(() => buildHistory({ config, roots, from: a, to: b, timezone, scope: 'all', updates: true, hiddenSessions: 'redacted', usage: true, keepRaw: true }))
1157
+ .then(() => buildHistory({ config, roots, from: a, to: b, timezone, scope: 'all', updates: true, hiddenSessions: 'redacted', usage: true, keepRaw: true, signal, ...(back ? { saved: back.sessions } : {}) }))
1094
1158
  .then(
1095
1159
  // Only the counts are kept: the history and the checks' result go with this function.
1096
- (h) => ({ window: w, ...trendCounts(runProblems(h, { builtT: now(), longSessionTokens: longSession })), ms: now() - t0 }),
1160
+ (h) => ({ window: w, ...trendCounts(runProblems(h, { builtT: now(), longSessionTokens: longSession, ...(back ? { saved: back.findings } : {}) })), ms: now() - t0 }),
1097
1161
  (err) => ({ window: w, error: reason(err), ms: now() - t0 }),
1098
1162
  );
1099
1163
  };
@@ -1252,7 +1316,20 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
1252
1316
  return { sessions, totals: Object.fromEntries(Object.entries(totals).map(([k, t]) => [k, { sessions: t.sessions, facts: shown(t.facts) }])) };
1253
1317
  }
1254
1318
 
1255
- const ROUTES = { '/api/home': home, '/api/sessions': sessionsList, '/api/problems': problems, '/api/insights': insightsRoute, '/api/facts': factsRoute, '/api/lookup': lookup, '/api/words': wordSearch, '/api/goal': goal, '/api/replay': replay, '/api/record': recordRoute, '/api/search': search, ...(selfTest ? { '/api/selftest': selftestInfo } : {}) };
1319
+ /** Settings' "Suggest words": name-shaped words in my configured sessions' prompts and titles.
1320
+ * Always from the redacted build, whichever version the page shows, so a word already hidden
1321
+ * never comes back as a suggestion. */
1322
+ function suggestedWords() {
1323
+ const { h, redact } = context('redacted');
1324
+ // A prompt's shown text is an excerpt, which can miss a later name or cut a code block
1325
+ // open; the whole prompt the engine keeps in memory is scanned instead, redacted first.
1326
+ const promptText = (e) => (typeof e._raw?.text === 'string' ? redact(e._raw.text) : e.facts?.text);
1327
+ const listed = ['names', 'terms', 'codenames'].flatMap((k) => (Array.isArray(config?.redaction?.[k]) ? config.redaction[k] : []));
1328
+ const labels = (Array.isArray(config?.repos) ? config.repos : []).flatMap((r) => (typeof r?.label === 'string' ? r.label.split(/[^\p{L}\p{N}]+/u) : []));
1329
+ return { words: suggestWords({ sessions: h.sessions, events: h.events, include: (s) => sessionGroup(s) === 'configured', exclude: [...listed, ...labels], promptText }) };
1330
+ }
1331
+
1332
+ const ROUTES = { '/api/suggest-words': suggestedWords, '/api/home': home, '/api/sessions': sessionsList, '/api/problems': problems, '/api/insights': insightsRoute, '/api/facts': factsRoute, '/api/lookup': lookup, '/api/words': wordSearch, '/api/goal': goal, '/api/replay': replay, '/api/record': recordRoute, '/api/search': search, ...(selfTest ? { '/api/selftest': selftestInfo } : {}) };
1256
1333
 
1257
1334
  /** Answer one data request. `params` is the request's URLSearchParams. */
1258
1335
  async function route(path, params) {
@@ -1306,7 +1383,14 @@ export function createViewData({ config, roots, from, to, timezone, goalRecord =
1306
1383
  signal.aborted = true;
1307
1384
  }
1308
1385
 
1309
- return { start, stop, status, route, leakCheck, codexWork, routes: ['/api/status', ...Object.keys(ROUTES)] };
1386
+ /** Settles once "search everywhere" has read the logs, or failed to (after start()). */
1387
+ const searchReady = () => words.promise ?? Promise.resolve();
1388
+
1389
+ /** matchSession over the redacted build, for the commands in this process only: no route
1390
+ * answers it, so a log's own id never reaches the page. Null until that build is ready. */
1391
+ const sessionMatch = (text) => (builds.redacted.state === 'ready' ? matchSession(builds.redacted.h, text) : null);
1392
+
1393
+ return { start, stop, status, route, leakCheck, codexWork, searchReady, sessionMatch, routes: ['/api/status', ...Object.keys(ROUTES)] };
1310
1394
  }
1311
1395
 
1312
1396
  /** The text a tool call's result record holds, from either harness's record shape. */
@@ -0,0 +1,119 @@
1
+ // lib/view/own-week.mjs: the person's own week, as `honestweek view` reads it and the find,
2
+ // replay, problems and goals commands (lib/ask.mjs) read it too: the config the lookup finds,
3
+ // the window the options or the config's "history" name, the log folders, and the goal list.
4
+ // Read-only: it opens the config and the goal list, and sizes the logs by their files alone.
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, isAbsolute, resolve } from 'node:path';
8
+
9
+ import { HISTORY_LIMIT_MB, loadConfig } from '../config.mjs';
10
+ import { findConfig } from '../config-lookup.mjs';
11
+ import { currentCommand } from '../invocation.mjs';
12
+ import { normalizeGoalRecord } from '../replay/goals.mjs';
13
+ import { defaultRoots } from '../replay/sources.mjs';
14
+ import { localDateInTimezone } from '../resolve-week.mjs';
15
+ import { withSavedDays } from '../saved/history.mjs';
16
+ import { savedDirOf, savedOptions } from '../saved/store.mjs';
17
+ import { logFiles, planWindow } from './window.mjs';
18
+
19
+ const DEFAULT_DAYS = 7;
20
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
21
+
22
+ /** A plain message for a setting that can't be read: `view` prints it and stops. */
23
+ export class SetupError extends Error {}
24
+ export const setup = (message) => new SetupError(message);
25
+
26
+ export const validDate = (s) => DATE_RE.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`)) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
27
+ export const spanDays = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000) + 1;
28
+ export const shiftDay = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
29
+
30
+ export function validTimezone(tz) {
31
+ try {
32
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** The window to read: --from/--to, or the last --days days (default 7) ending today. */
40
+ export function resolveViewWindow(values, timezone, now = Date.now()) {
41
+ const hasRange = '--from' in values || '--to' in values;
42
+ if (hasRange && '--days' in values) throw setup('view: use --days, or --from with --to, not both.');
43
+ if (hasRange) {
44
+ const from = values['--from'];
45
+ const to = values['--to'];
46
+ if (from === undefined || to === undefined) throw setup('view: --from and --to go together. Give both, or use --days.');
47
+ for (const [name, v] of [['--from', from], ['--to', to]]) if (!validDate(v)) throw setup(`view: ${name} must be a date written YYYY-MM-DD (got ${JSON.stringify(v)}).`);
48
+ if (from > to) throw setup(`view: --from (${from}) is after --to (${to}).`);
49
+ return { from, to };
50
+ }
51
+ let days = DEFAULT_DAYS;
52
+ if ('--days' in values) {
53
+ const v = values['--days'];
54
+ if (!/^\d{1,4}$/.test(v) || Number(v) < 1 || Number(v) > 3660) throw setup(`view: --days must be a whole number from 1 to 3660 (got ${JSON.stringify(v)}).`);
55
+ days = Number(v);
56
+ }
57
+ const today = localDateInTimezone(new Date(now), timezone).toISOString().slice(0, 10);
58
+ return { from: shiftDay(today, -(days - 1)), to: today };
59
+ }
60
+
61
+ /**
62
+ * readGoalList(path) -> the parsed goal list. Throws a setup error with a plain message
63
+ * when the file is missing, can't be read as JSON, is the goals page's registry, or doesn't fit.
64
+ */
65
+ export function readGoalList(path) {
66
+ if (!existsSync(path)) throw setup(`view: no goal list at ${path}. Fix the path, or leave the goal list out; search and replay work without one.`);
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
70
+ } catch {
71
+ // Never the parser's message: it quotes the file's first characters, and this check takes
72
+ // any path. A folder or an unreadable file reads the same as a file that isn't JSON.
73
+ throw setup(`view: ${path} isn't a goal list (not valid JSON).`);
74
+ }
75
+ const looksLikeRegistry = parsed && typeof parsed === 'object' && !Array.isArray(parsed.goals) && (parsed.objectives !== undefined || parsed.projectToObjective !== undefined);
76
+ if (looksLikeRegistry) {
77
+ throw setup(`view: ${path} looks like the goals page's list (its "objectives"), which honestweek build reads. view reads a different file, a goal list: { "goals": [{ "id": "g-1", "title": "Ship the parser" }], "events": [] }. The README's goal list section shows the format.`);
78
+ }
79
+ try {
80
+ normalizeGoalRecord(parsed);
81
+ } catch (err) {
82
+ throw setup(`view: ${path} isn't a goal list: ${String(err?.message ?? err).replace(/^replay: /, '')}`);
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ /** The config's limit on log data a saved choice loads, in MB. */
88
+ export const limitOf = (config) => config?.historyLimitMB ?? HISTORY_LIMIT_MB.default;
89
+
90
+ export const absolute = (cwd, p) => (isAbsolute(p) ? p : resolve(cwd, p));
91
+
92
+ /** The person's own week: the config, the window and the goal list the options and the config
93
+ * name. Throws a setup error with a plain message when one of them can't be read. The find,
94
+ * replay, problems and goals commands (lib/ask.mjs) read the same week. */
95
+ export function ownWeek({ values, cwd, env, now }) {
96
+ const configPath = findConfig({ cwd, flag: values['--config'] }).path;
97
+ if (!existsSync(configPath)) throw setup(`view: no config at ${configPath}. Run ${currentCommand()} view in a folder with no config to set one up in your browser (or ${currentCommand()} init in a terminal), or ${currentCommand()} view --demo to look around a made-up week first.`);
98
+ let config;
99
+ try {
100
+ config = loadConfig(configPath);
101
+ } catch (err) {
102
+ throw setup(`view: ${err.message}`);
103
+ }
104
+ const timezone = values['--timezone'] ?? config.week?.timezone;
105
+ if (!validTimezone(timezone)) throw setup(`view: ${JSON.stringify(timezone)} isn't a timezone this machine knows. Use an IANA name such as Europe/Paris or UTC.`);
106
+ const roots = defaultRoots(env);
107
+ // The flags win for one run. With none, a saved choice is sized to fit (the last week, or up to
108
+ // 7 days, always whole); with no saved choice either, it's the last 7 days, as it always was.
109
+ // Either way a window of more than one day loads its newest day first.
110
+ const flagged = ['--days', '--from', '--to'].some((f) => f in values);
111
+ // With each day's history saved, a saved day with no log left counts too: "all history" reaches
112
+ // back to the oldest one, and the limit counts what it brings back.
113
+ const files = savedOptions(config)?.history ? withSavedDays(logFiles(roots, timezone), savedDirOf(dirname(configPath))) : undefined;
114
+ const plan = !flagged && config.history ? planWindow(config.history, { roots, timezone, now: now(), maxBytes: limitOf(config) * 1024 * 1024, ...(files ? { files } : {}) }) : null;
115
+ const { from, to } = plan ?? resolveViewWindow(values, timezone, now());
116
+ const goalsPath = values['--goals'] ? absolute(cwd, values['--goals']) : config.goalsFile ?? null;
117
+ const goalRecord = goalsPath ? readGoalList(goalsPath) : null;
118
+ return { config, configPath, roots, from, to, timezone, goalRecord, windowNote: plan?.note ?? null, ...(files ? { files } : {}) };
119
+ }
@@ -0,0 +1,84 @@
1
+ // lib/view/page-link.mjs: which page, query and step a one-time address from `view` may open
2
+ // (issue 198), so an agent or a person can hand someone a link straight to a session or a step.
3
+ //
4
+ // Only view's own pages are accepted, never a path: nothing here joins a path from the request,
5
+ // and the server serves a fixed list of files anyway. The query is parsed and written back
6
+ // encoded, with plain keys only. The part after `#` is the replay page's thread and step, so it
7
+ // takes ids only: letters, digits and `_ . ~ : -`, which also keeps out an `&c=` that would
8
+ // pose as the one-time code key.js reads from that same part of the address. Search is the one
9
+ // page with a part of its own: the id of a search this run keeps in memory (#q=<id>~l or ~w,
10
+ // as search.js writes it), and no query, since its address never holds what was typed.
11
+
12
+ /** The pages a link may open. Setup has its own flow, and the self-test its own address. */
13
+ export const LINK_PAGES = Object.freeze(['problems.html', 'replay.html', 'search.html', 'goal.html', 'settings.html']);
14
+
15
+ const KEY = /^[A-Za-z]{1,24}$/;
16
+ const FRAGMENT = /^[A-Za-z0-9_.~:-]{1,300}$/;
17
+ /** Search's own part after `#`: a search id this run gave out (QUERY_ID_RE in data.mjs). */
18
+ const SEARCH_FRAGMENT = /^q=q[a-p]{16}(?:~[lw])?$/;
19
+
20
+ /** The page's own ids, as `ID` in lib/view/assets/common.js has them (a test keeps the two the same). */
21
+ export const PAGE_IDS = Object.freeze({
22
+ thread: /^th-[a-p]{4,64}$/,
23
+ session: /^[a-z]{2,4}-[a-p]{4,64}$/,
24
+ event: /^[a-z]{2,4}-[a-p]{4,64}(?:[.:][A-Za-z0-9_-]{1,80}){0,6}$/,
25
+ goal: /^(?:[a-z]{1,4}-)?[a-p]{4,64}$/,
26
+ });
27
+ const isId = (kind, v) => typeof v === 'string' && v.length <= 300 && PAGE_IDS[kind].test(v);
28
+
29
+ /**
30
+ * replayPage({ session, thread, event }) -> 'replay.html?session=<id>#<thread>~<step>' | null
31
+ * A session's replay, or one step of it, built the way the Problems page builds its own links:
32
+ * ids only, so null for anything that isn't one of the page's ids.
33
+ */
34
+ export function replayPage({ session, thread = null, event = null } = {}) {
35
+ if (!isId('session', session)) return null;
36
+ const t = isId('thread', thread) ? thread : '';
37
+ const e = isId('event', event) ? `~${event}` : '';
38
+ return `replay.html?session=${encodeURIComponent(session)}${t || e ? `#${t}${e}` : ''}`;
39
+ }
40
+
41
+ /**
42
+ * zoomPage(finding) -> 'replay.html?session=<id>#<thread>~zoom~<finding key>' | null
43
+ * The replay zoomed to the steps a finding's check recorded, in its own thread's lane when it has
44
+ * one, else its first: the same link the Problems page builds (zoomHref in assets/problems.js).
45
+ */
46
+ export function zoomPage(f) {
47
+ const lanes = f?.zoom?.lanes;
48
+ if (!/^pf-[a-p]{12}$/.test(f?.key ?? '') || !Array.isArray(lanes) || !lanes.length) return null;
49
+ const lane = lanes.find((l) => l.thread === f.thread) ?? lanes[0];
50
+ if (!isId('session', lane?.session) || !isId('thread', lane?.thread)) return null;
51
+ return `replay.html?session=${encodeURIComponent(lane.session)}#${lane.thread}~zoom~${f.key}`;
52
+ }
53
+
54
+ /** A goal's page, 'goal.html#<key>', or null. */
55
+ export const goalPage = (key) => (isId('goal', key) ? `goal.html#${key}` : null);
56
+
57
+ /** The Problems page narrowed to one session's findings, 'problems.html?session=<key>', or null. */
58
+ export const problemsPage = (session) => (isId('session', session) ? `problems.html?session=${encodeURIComponent(session)}` : null);
59
+
60
+ /**
61
+ * pageLink(text) -> { page } | { error }
62
+ * `page` is what follows the server's address: a page name, an optional `?query` and an
63
+ * optional `#fragment`, or '' for the Problems page. `error` is one plain line.
64
+ */
65
+ export function pageLink(text) {
66
+ const raw = String(text ?? '').trim().replace(/^\/+/, '');
67
+ if (!raw) return { page: '' };
68
+ const hashAt = raw.indexOf('#');
69
+ const head = hashAt < 0 ? raw : raw.slice(0, hashAt);
70
+ const fragment = hashAt < 0 ? '' : raw.slice(hashAt + 1);
71
+ const queryAt = head.indexOf('?');
72
+ const name = queryAt < 0 ? head : head.slice(0, queryAt);
73
+ if (!LINK_PAGES.includes(name)) return { error: `${JSON.stringify(name.slice(0, 80))} isn't one of view's pages: ${LINK_PAGES.join(', ')}.` };
74
+ const params = new URLSearchParams(queryAt < 0 ? '' : head.slice(queryAt + 1));
75
+ for (const key of params.keys()) {
76
+ if (!KEY.test(key)) return { error: `${JSON.stringify(key.slice(0, 40))} isn't a key view's pages read.` };
77
+ }
78
+ const query = params.toString();
79
+ if (name === 'search.html') {
80
+ if (query) return { error: "Search takes no query: its address never holds what was typed. Copy a search's address from the page, #q= and all." };
81
+ if (fragment && !SEARCH_FRAGMENT.test(fragment)) return { error: "Search's part after # can only be a search's own id, as the page's address shows it (#q=<id>~w)." };
82
+ } else if (fragment && !FRAGMENT.test(fragment)) return { error: 'the part after # can hold only ids (letters, digits, and _ . ~ : -).' };
83
+ return { page: `${name}${query ? `?${query}` : ''}${fragment ? `#${fragment}` : ''}` };
84
+ }