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/validate.mjs CHANGED
@@ -23,6 +23,7 @@ import { existsSync, readFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
 
25
25
  import { loadConfig } from './config.mjs';
26
+ import { commandConfig } from './config-lookup.mjs';
26
27
  import { STATUSES } from './badges.mjs';
27
28
  import { localDateInTimezone, resolveWeek } from './resolve-week.mjs';
28
29
  import { loadValidatedPromptLane } from './prompt-lane.mjs';
@@ -172,14 +173,22 @@ export function validateItems(items, config, { noDashes = false } = {}) {
172
173
  /**
173
174
  * runValidate({ cwd, argv, io }) -> exit code (0 clean, 2 problems, 1 setup error).
174
175
  */
175
- export async function runValidate({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), promptLockHeld = false, transactionFs = {} } = {}) {
176
+ export async function runValidate({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), promptLockHeld = false, transactionFs = {}, found } = {}) {
176
177
  if (!promptLockHeld) {
178
+ // The config can sit elsewhere (--config, HONESTWEEK_CONFIG, the user-level file); every file
179
+ // validate reads or writes then sits beside it, never in an unrelated folder this ran in.
180
+ const opened = commandConfig({ command: 'validate', cwd, argv, err: io.err });
181
+ if (opened.error) {
182
+ io.err(`validate: ${opened.error}\n`);
183
+ return io.exit(1) ?? 1;
184
+ }
185
+ ({ cwd, argv } = opened);
177
186
  let code;
178
187
  try {
179
188
  assertNoDigestPending(cwd);
180
189
  code = await withPromptLock(
181
190
  cwd,
182
- () => runValidate({ cwd, argv, now, io: { out: io.out, err: io.err, exit: (value) => value }, promptLockHeld: true, transactionFs }),
191
+ () => runValidate({ cwd, argv, now, io: { out: io.out, err: io.err, exit: (value) => value }, promptLockHeld: true, transactionFs, found: opened.found }),
183
192
  { ensureIgnored: false },
184
193
  );
185
194
  } catch (err) {
@@ -192,7 +201,7 @@ export async function runValidate({ cwd = process.cwd(), argv = [], now = new Da
192
201
 
193
202
  let config;
194
203
  try {
195
- config = loadConfig(join(cwd, CONFIG_FILE));
204
+ config = loadConfig(found?.path ?? join(cwd, CONFIG_FILE));
196
205
  } catch (err) {
197
206
  io.err(`validate: ${err.message}\n`);
198
207
  return io.exit(1) ?? 1;
@@ -732,6 +732,8 @@ details.legendbox > summary, details.catalog > summary { color: var(--ink-2); }
732
732
  .setup { max-width: 760px; margin: 0 auto; padding: 24px 24px 8px; }
733
733
  .setup h1 { font-size: 22px; font-weight: 600; margin: 0 0 8px; }
734
734
  .setup .lead { color: var(--ink-2); margin: 0 0 20px; }
735
+ /* A folder path has no spaces to break at, so it breaks anywhere rather than run off a phone. */
736
+ .setup .fine { overflow-wrap: anywhere; }
735
737
  .setup-part { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 20px; margin: 0 0 16px; }
736
738
  .setup-part h2 { font-size: 16px; font-weight: 600; margin: 0 0 8px; }
737
739
  .setup-part .fine { font-size: 13px; color: var(--ink-2); margin: 6px 0; }
@@ -785,7 +785,7 @@
785
785
  const line = r.ref?.line ?? r.line;
786
786
  if (r.git || r.ref?.sha) return `<h3>${title}</h3><p>A git object: the engine checked it with git.</p>`;
787
787
  const body = typeof r.body === 'string' ? r.body : r.record != null ? (typeof r.record === 'string' ? r.record : JSON.stringify(r.record, null, 2)) : '';
788
- return `<h3>${title}</h3><p>${r.verified ? '<span class="verified">✓ re-read from disk, fingerprint matches</span>' : `<span class="notverified">✗ ${esc(r.note ?? 'not checked')}</span>`}${line ? ` · line ${esc(line)}` : ''}</p>${body ? `<pre>${esc(body)}</pre>` : ''}`;
788
+ return `<h3>${title}</h3><p>${r.verified ? '<span class="verified">✓ re-read from disk, fingerprint matches</span>' : r.saved ? `<span class="muted">${esc(r.note ?? 'saved')}</span>` : `<span class="notverified">✗ ${esc(r.note ?? 'not checked')}</span>`}${line ? ` · line ${esc(line)}` : ''}</p>${body ? `<pre>${esc(body)}</pre>` : ''}`;
789
789
  };
790
790
  const output = typeof a.output === 'string' ? a.output : typeof a.output?.text === 'string' ? a.output.text : null;
791
791
  let html = '';
@@ -991,12 +991,30 @@
991
991
  return `${own} ${help('What launched it?', why)}`;
992
992
  }
993
993
 
994
+ /**
995
+ * A session that came back from saved results (honestweek.saved/): "Saved Oct 6, 2026, log gone",
996
+ * with when, by which version and what that means behind a "?" (or in a tooltip, `brief`).
997
+ * Nothing for any other session.
998
+ */
999
+ function savedHtml(saved, { brief = false } = {}) {
1000
+ if (!saved || typeof saved !== 'object') return '';
1001
+ const t = toT(saved.at);
1002
+ const day = Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : 'an earlier run';
1003
+ const gone = saved.log === 'gone';
1004
+ const short = gone ? `Saved ${day}, log gone` : `Saved ${day}`;
1005
+ const by = typeof saved.by === 'string' ? ` by honestweek ${saved.by}` : '';
1006
+ const why = gone
1007
+ ? `Saved on ${day}${by}, from a log that's no longer on disk. Its steps can't be checked against their log lines, and each keeps the evidence word it had then. Private text is never saved, so Show private text shows it redacted.`
1008
+ : `Saved on ${day}${by}.`;
1009
+ return brief ? `<span class="muted saved-tag" title="${esc(why)}">${esc(short)}</span>` : `<span class="muted saved-tag">${esc(short)}</span> ${help('About saved sessions', why)}`;
1010
+ }
1011
+
994
1012
  /** Extra lines a page adds to a step's record panel, each a function of the step's id. */
995
1013
  const stepNotes = [];
996
1014
  const addStepNote = (fn) => stepNotes.push(fn);
997
1015
 
998
1016
  window.HW = {
999
- launchHref, sessionHref, startedByHtml, addStepNote, PROGRAM_START,
1017
+ launchHref, sessionHref, startedByHtml, savedHtml, addStepNote, PROGRAM_START,
1000
1018
  esc, plural, pct, toT, num, lvl, time, dayOf, dur, windowText, windowShort, daySpan, friendlyDates, loadText, showLoad, showWindow, nothingIn, setTimezone, command,
1001
1019
  shell, start, load, fail, fatal, setStatus, addNote, isId, ID, navCount, problemsSummary,
1002
1020
  chip, chips: HWE.chips, sym: HWE.sym, ruleText, authorship, who, timeHow, timeMoved, memberLevel, kindLabel, lowerFirst, stripKind, summary, describeStep, describeStepPieces, clip, clipHtml, GROUP_LABEL,
@@ -126,7 +126,7 @@
126
126
  const agentTag = (f) => {
127
127
  const a = agentOf(f);
128
128
  const repo = D.sessions?.[f.session]?.repo;
129
- return `${a ? `<span class="agent">${esc(a)}</span>` : ''}${repo ? `<span class="repotag" title="Repository">${esc(repo)}</span>` : ''}`;
129
+ return `${a ? `<span class="agent">${esc(a)}</span>` : ''}${repo ? `<span class="repotag" title="Repository">${esc(repo)}</span>` : ''}${(HW.savedHtml ?? (() => ''))(D.sessions?.[f.session]?.saved, { brief: true })}`;
130
130
  };
131
131
  const timeHtml = (f) => (f.at ? `<time datetime="${esc(f.at)}">${esc(when(f.at))}</time>` : '<span class="muted">no time</span>');
132
132
  // The plain past-tense headline says it happened, so it titles only a pattern found in the log;
@@ -206,6 +206,7 @@
206
206
  : e?.skipped ? HW.friendlyDates(e.note ?? "The week before isn't loaded, so nothing is compared.")
207
207
  : e?.partial || e?.loaded ? HW.friendlyDates(e.note ?? '')
208
208
  : e && e.sessions === 0 ? `No logs in the ${plural(e.days ?? 0, 'day')} before, so no trend yet.`
209
+ : e?.saved ? 'Compared with the week before from its saved results.'
209
210
  : '';
210
211
  el.hidden = !text;
211
212
  el.textContent = text;
@@ -595,7 +596,7 @@
595
596
  <p><b>Measures.</b> ${(c.patterns ?? []).filter((id) => PAT.has(id)).map((id) => { const src = sourcesLink(PAT.get(id)); return `<a href="#${esc(id)}">${esc(titleOf(PAT.get(id)))}</a>${src ? ` <span class="nowrap">(${src})</span>` : ''}`; }).join('; ')}</p>
596
597
  <p><b>How this is known.</b> ${esc(c.how)} Its findings are ${chip(c.evidence)} or stronger where marked.</p>
597
598
  ${numbersHtml(c)}
598
- ${c.ran ? `<p><b>Checked.</b> ${esc(c.checked)}</p>` : `<p><b>Not run.</b> ${esc(c.notRun ?? '')}</p>`}
599
+ ${c.ran ? `<p><b>Checked.</b> ${esc(c.checked)}</p>` : c.saved ? `<p><b>From saved results.</b> ${esc(plural(num(c.saved.findings), 'finding'))} from when it checked the sessions that came back unread. It didn't run on the logs read this time.</p>` : `<p><b>Not run.</b> ${esc(c.notRun ?? '')}</p>`}
599
600
  ${c.stats?.length ? `<ul class="stgrid">${c.stats.map((s) => `<li><span class="sl">${esc(s.label)}</span> <span class="sv">${statVal(s)} ${s.evidence ? chip(s.evidence) : ''}</span></li>`).join('')}</ul>` : ''}
600
601
  ${used.length ? `<details class="rules"><summary>The ${used.length === 1 ? 'rule' : `${used.length} rules`} behind it</summary><dl>${used.map((id) => `<dt><code>${esc(id)}</code></dt><dd>${esc(rules.get(id))}${sourceOf.get(id) ? (sourceOf.get(id).covers ? ` For ${esc(sourceOf.get(id).covers)}, from ${sourceLink(sourceOf.get(id))}; the rest is ${OWN}.` : ` From ${sourceLink(sourceOf.get(id))}.`) : ''}</dd>`).join('')}</dl>${rulesBasis(used, sourceOf)}</details>` : ''}
601
602
  </div>
@@ -612,7 +613,7 @@
612
613
  <p><b>Which agent.</b> Each finding names the agent its session ran on. A card says "Partial on Codex" or "Not on Codex yet" (or the same for Claude Code) when its check reads that agent's logs only in part or not at all; with no such mark, the check runs on both.</p>
613
614
  <p><b>The catalog.</b> ${esc(D.catalog?.about ?? '')} Its ${esc(full(D.catalog?.sources ?? 0))} sources were checked on ${esc(checked || 'the dates the catalog records')}.</p>
614
615
  <p><b>Detection is from local logs only.</b> Nothing here calls out to the web or reads anything but the session files this page already read. "Not checked" means a pattern wasn't looked for, so it says nothing about whether it happened; "can't be checked from logs" means the catalog says a log can't show it.</p>
615
- <p><b>What the counts cover.</b> ${esc(plural(num(cov.sessions), 'session'))} in your configured repositories with records in this window, ${esc(plural(num(cov.toolCalls), 'tool call'))}${cov.usageRecorded ? ` and ${esc(plural(num(cov.modelCalls), 'model call'))} with token counts (${tok(num(cov.tokens))} tokens in all)` : ', and no token counts, so the checks that measure tokens didn\'t run'}. Token estimates overlap between checks, so they don't add up to a total.</p>
616
+ <p><b>What the counts cover.</b> ${esc(plural(num(cov.sessions), 'session'))} in your configured repositories with records in this window, ${esc(plural(num(cov.toolCalls), 'tool call'))}${cov.usageRecorded ? ` and ${esc(plural(num(cov.modelCalls), 'model call'))} with token counts (${tok(num(cov.tokens))} tokens in all)` : ', and no token counts, so the checks that measure tokens didn\'t run'}. Token estimates overlap between checks, so they don't add up to a total.${num(cov.saved) > 0 ? ` Of these, ${esc(plural(num(cov.saved), 'session'))} came back from saved results unread, since their logs haven't changed or are gone: their findings are the ones saved with them, and what each check says it read counts only the sessions read this run.` : ''}</p>
616
617
  <p><b>Fixes are for you to adopt.</b> Copy puts the catalog's draft on your clipboard. Nothing on this page changes your settings, hooks, skills or instructions.</p>`;
617
618
  }
618
619
 
@@ -2023,7 +2023,9 @@
2023
2023
  // What started the session shown, when a program did: the step that launched it, linked,
2024
2024
  // or a "?" that says why none is named.
2025
2025
  const shownKey = opened ? opened.a.session : multi ? null : sessions[0]?.key ?? null;
2026
- const started = startedLine(shownKey, events, sessions);
2026
+ // A session shown from saved results says so, once, beside what started it.
2027
+ const savedRow = sessions.find((s) => s.saved && (!shownKey || s.key === shownKey)) ?? null;
2028
+ const started = [startedLine(shownKey, events, sessions), savedRow ? (HW.savedHtml ?? (() => ''))(savedRow.saved) : ''].filter(Boolean).join(' · ') || null;
2027
2029
  $('startedBy').hidden = !started;
2028
2030
  $('startedBy').innerHTML = started ?? '';
2029
2031
 
@@ -176,7 +176,7 @@
176
176
  // ---- a reference: /api/lookup ------------------------------------------------------------
177
177
  const PR_RE = /^(?:(?:[\w.-]+\/)?[\w.-]+#|pr\s*#?\s*|pull\/|#)(\d+)$/i;
178
178
  const PR_URL = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/i;
179
- const isRef = (q) => 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)));
179
+ const isRef = (q) => /^(pr|pull|commit|path):/i.test(q) || 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)));
180
180
  // A lookup answer: `kind`, the engine's reading of the reference in `parsed`, and on a restore by
181
181
  // id the typed text in `query`.
182
182
  function renderLookup(a) {
@@ -35,6 +35,7 @@
35
35
  esc(toolName(s.tool)),
36
36
  s.repo ? esc(s.repo) : '',
37
37
  GROUP_NOTE[s.group] ?? '',
38
+ (HW.savedHtml ?? (() => ''))(s.saved, { brief: true }),
38
39
  opened ? HW.startedByHtml(opened, s.firstAt) : Number.isFinite(num(s.prompts)) ? `${plural(num(s.prompts), 'prompt')} ${sym(lvl(s.prompts, 'derived'))}` : '',
39
40
  look,
40
41
  ].filter(Boolean).join(' · ');
@@ -68,6 +68,9 @@
68
68
  <label for="terms" id="termsLabel"></label>
69
69
  <input id="terms" type="text" autocomplete="off" spellcheck="false">
70
70
  </div>
71
+ <button type="button" class="btn" id="suggestBtn">Suggest words from my sessions</button>
72
+ <p class="fine" id="suggestNote" role="status" hidden></p>
73
+ <ul class="repolist" id="suggestList" hidden></ul>
71
74
  </details>
72
75
  <section class="setup-part" aria-labelledby="goalsH">
73
76
  <h2 id="goalsH"><label for="goals">Goal list</label> <span id="goalsHelp"></span></h2>
@@ -80,6 +83,19 @@
80
83
  <h2 id="longH"><label for="longLimit">Long-session limit</label> <span id="longHelp"></span></h2>
81
84
  <input class="setup-input" id="longLimit" type="text" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="Off">
82
85
  </section>
86
+ <section class="setup-part" aria-labelledby="saveH">
87
+ <h2 id="saveH"><label><input type="checkbox" id="saveOn" autocomplete="off"> Save results between runs</label> <span id="saveHelp"></span></h2>
88
+ <div class="setup-inline">
89
+ <label for="saveKeep">Keep saved days for</label>
90
+ <input id="saveKeep" type="number" min="1" max="36500" autocomplete="off">
91
+ <span>days</span>
92
+ </div>
93
+ <label class="fine"><input type="checkbox" id="saveHistory" autocomplete="off"> Each day's history too, for Replay</label>
94
+ <label class="fine"><input type="checkbox" id="saveOthers" autocomplete="off"> Also display-only and outside sessions' history</label>
95
+ <p class="fine muted" id="savedLine"></p>
96
+ <button type="button" class="btn" id="forgetBtn" hidden>Forget saved results</button>
97
+ <p class="fine" id="forgetNote" role="status" hidden></p>
98
+ </section>
83
99
  <div class="setup-inline setup-actions">
84
100
  <button type="button" class="btn" id="previewBtn">Review changes</button>
85
101
  <button type="button" class="btn primary" id="saveBtn">Save</button>
@@ -1,6 +1,7 @@
1
1
  // The Settings page: change this folder's config later, without the terminal. How far back
2
2
  // to look, the repositories and their roles, the emails, the private words, the goal list and
3
- // whether the Problems page includes /insights, and its long-session limit.
3
+ // whether the Problems page includes /insights, its long-session limit, and whether results are
4
+ // saved between runs, for how long, with a button that deletes what's saved.
4
5
  // "Review changes" asks the server what would change, in plain words; Save writes it and the
5
6
  // same server reloads the week, so the next page shows the new settings.
6
7
  //
@@ -29,8 +30,37 @@
29
30
  goalsFile: $('goals').value.trim(),
30
31
  insights: $('insights').checked,
31
32
  longSessionTokens: tokensOf($('longLimit').value),
33
+ saveResults: { on: $('saveOn').checked, keepDays: daysOf($('saveKeep').value), history: $('saveHistory').checked, otherSessions: $('saveOthers').checked },
32
34
  };
33
35
  }
36
+ /** Days as typed: empty is the default (null), a whole number is itself, and anything else goes
37
+ * to the server as typed, which says what it takes. */
38
+ function daysOf(text) {
39
+ const t = String(text).trim();
40
+ if (!t) return null;
41
+ return /^\d+$/.test(t) ? Number(t) : t;
42
+ }
43
+ /** "12 days saved, 340 KB", or why nothing can be. */
44
+ function savedText(s) {
45
+ if (!s?.available) return s?.note ?? '';
46
+ if (!s.days && !s.bytes) return s.error ? `Nothing saved yet. The last save failed: ${s.error}.` : 'Nothing saved yet.';
47
+ const size = s.bytes >= 1048576 ? `${(s.bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(s.bytes / 1024))} KB`;
48
+ const line = `${s.days} ${s.days === 1 ? 'day' : 'days'} saved, ${size}.`;
49
+ return s.error ? `${line} The last save failed: ${s.error}.` : line;
50
+ }
51
+ /** "Forget saved results": one confirm, then the server deletes the whole folder. */
52
+ async function forget() {
53
+ if (!window.confirm("Delete every saved result? Days whose logs are gone can't be rebuilt.")) return;
54
+ $('forgetBtn').disabled = true;
55
+ const r = await F.call('POST', 'saved/forget', {});
56
+ $('forgetNote').hidden = false;
57
+ $('forgetNote').textContent = r.body?.message ?? `The server answered ${r.status}.`;
58
+ if (r.status === 200) {
59
+ $('savedLine').textContent = 'Nothing saved.';
60
+ $('forgetBtn').hidden = true;
61
+ }
62
+ $('forgetBtn').disabled = false;
63
+ }
34
64
  /** A token count as typed: empty is off (null), "150k" and "150,000" are 150000, and anything
35
65
  * else goes to the server as typed, which says what it takes. */
36
66
  function tokensOf(text) {
@@ -64,7 +94,7 @@
64
94
  }
65
95
  const items = [...r.body.changes, ...(r.body.notes ?? [])];
66
96
  if (!r.body.changes.length) {
67
- list([]);
97
+ list(r.body.notes ?? []);
68
98
  F.problem('Nothing changed.');
69
99
  return;
70
100
  }
@@ -76,7 +106,8 @@
76
106
  F.problem('');
77
107
  F.status('Saving.');
78
108
  const r = await F.call('POST', 'settings/save', sent);
79
- // A refusal, or nothing to change, writes nothing: say so and leave Save on. A 409 (the file
109
+ // A refusal, or nothing to change, writes no config (nothing to change may still add the
110
+ // config's .gitignore line): say so and leave Save on. A 409 (the file
80
111
  // changed on disk, or this run can't edit it) needs a reload, so Save stays off.
81
112
  if (r.status !== 200 || r.body.saved === false) {
82
113
  F.status('');
@@ -86,9 +117,18 @@
86
117
  }
87
118
  if (r.body.restart) {
88
119
  F.status(`Saved. ${r.body.restart}`, { bad: true });
120
+ if (r.body.warning) list([r.body.warning]);
89
121
  document.body.dataset.ready = 'saved-restart';
90
122
  return;
91
123
  }
124
+ if (r.body.warning) {
125
+ // The config may be committed, or git couldn't be asked: say so before leaving the page.
126
+ F.status('Saved.');
127
+ list([r.body.warning]);
128
+ $('changes').append(F.el('li', {}, F.el('a', { href: 'problems.html', textContent: 'Load your week' })));
129
+ document.body.dataset.ready = 'saved-warning';
130
+ return;
131
+ }
92
132
  F.status('Saved. Loading your week.');
93
133
  document.body.dataset.ready = 'saved';
94
134
  location.href = 'problems.html';
@@ -131,6 +171,52 @@
131
171
  say();
132
172
  }
133
173
 
174
+ /** "Suggest words from my sessions": words in my prompts and session titles that look like
175
+ * names, each with buttons that add it to the names or the client words above. Nothing is
176
+ * saved until Save. */
177
+ async function suggest() {
178
+ const note = $('suggestNote');
179
+ const list = $('suggestList');
180
+ note.hidden = false;
181
+ note.textContent = 'Reading your prompts and session titles.';
182
+ list.hidden = true;
183
+ list.textContent = '';
184
+ const r = await F.call('GET', 'suggest-words');
185
+ if (r.status === 503) {
186
+ note.textContent = 'Your sessions are still loading. Try again in a moment.';
187
+ return;
188
+ }
189
+ if (r.status !== 200) {
190
+ note.textContent = r.body?.message ?? r.body?.error ?? `The server answered ${r.status}.`;
191
+ return;
192
+ }
193
+ const typed = () => new Set([$('names').value, $('terms').value].join(',').split(',').map((w) => w.trim().toLowerCase()).filter(Boolean));
194
+ const words = r.body.words.filter((x) => !typed().has(x.word.toLowerCase()));
195
+ const left = () => list.querySelectorAll('li').length;
196
+ const say = () => {
197
+ note.textContent = left() ? 'Words in your prompts that look like names. Add the private ones, then save.' : 'No words to suggest.';
198
+ list.hidden = !left();
199
+ };
200
+ for (const x of words) {
201
+ const li = F.el('li', { className: 'reporow' });
202
+ const button = (text, id) => {
203
+ const b = F.el('button', { type: 'button', className: 'btn', textContent: text });
204
+ b.setAttribute('aria-label', `Add ${x.word} to ${text === 'Name' ? 'names' : 'client words'}`);
205
+ b.addEventListener('click', () => {
206
+ const box = $(id);
207
+ box.value = box.value.trim() ? `${box.value.trim().replace(/,$/, '')}, ${x.word}` : x.word;
208
+ box.dispatchEvent(new Event('input'));
209
+ li.remove();
210
+ say();
211
+ });
212
+ return b;
213
+ };
214
+ li.append(F.el('div', { className: 'repomain' }, F.el('span', { className: 'reponame', textContent: x.word }), F.el('span', { className: 'repowhen', textContent: `seen ${x.count} times` })), button('Name', 'names'), button('Client word', 'terms'));
215
+ list.append(li);
216
+ }
217
+ say();
218
+ }
219
+
134
220
  async function start() {
135
221
  const state = await HWP.ready;
136
222
  const stop = (text) => {
@@ -152,6 +238,12 @@
152
238
  F.help($('emailHelp'), 'emails', ['A commit counts as yours only when one of these addresses wrote it. Separate several with commas.']);
153
239
  F.help($('goalsHelp'), 'the goal list', ['A JSON file of your goals and their changes. Leave it empty for none.']);
154
240
  F.help($('insHelp'), '/insights', ["Shows what Claude Code's /insights wrote about your sessions on the Problems page, as its own AI-written group."]);
241
+ F.help($('saveHelp'), 'saved results', [
242
+ "Keeps what each run works out, with private words hidden, in honestweek.saved/ beside your config, readable only by you and git-ignored. Each run adds to it, so problems --session can still say whether a session was checked, and what was found, after Claude Code deletes its log (after 30 days, by default).",
243
+ "With each day's history too, a session whose log is gone stays on Replay and Problems, marked saved. Check results alone take far less room.",
244
+ "History is kept for your configured repositories' sessions. Sessions from display-only repositories and folders outside your config, which the page also lists, are kept only with the last box ticked.",
245
+ 'Private text is never saved. Days older than the number you keep are deleted.',
246
+ ]);
155
247
  F.help($('longHelp'), 'the long-session limit', ['Problems flags an agent that kept going past this many tokens of context. Empty is off.', "No vendor recommends a number. The Claude API's own compaction starts at 150,000 unless you change it."]);
156
248
  repos = shown.repos.map((x) => ({ ...x }));
157
249
  const rl = F.repoList($('repos'), repos, changed, { newestFirst: true });
@@ -169,7 +261,19 @@
169
261
  $('insights').checked = shown.insights === true;
170
262
  $('insights').addEventListener('change', changed);
171
263
  $('longLimit').value = shown.longSessionTokens == null ? '' : shown.longSessionTokens.toLocaleString('en-US');
172
- for (const id of ['emails', 'names', 'terms', 'goals', 'longLimit']) $(id).addEventListener('input', changed);
264
+ $('saveOn').checked = shown.saveResults?.on === true;
265
+ $('saveKeep').placeholder = String(shown.saveResults?.defaultDays ?? 365);
266
+ $('saveKeep').value = shown.saveResults?.keepDays == null ? '' : String(shown.saveResults.keepDays);
267
+ $('saveHistory').checked = shown.saveResults?.history !== false;
268
+ $('saveOn').addEventListener('change', changed);
269
+ $('saveHistory').addEventListener('change', changed);
270
+ $('saveOthers').checked = shown.saveResults?.otherSessions === true;
271
+ $('saveOthers').addEventListener('change', changed);
272
+ $('savedLine').textContent = savedText(shown.saved);
273
+ $('forgetBtn').hidden = !(shown.saved?.available && (shown.saved.days || shown.saved.bytes));
274
+ $('forgetBtn').addEventListener('click', forget);
275
+ for (const id of ['emails', 'names', 'terms', 'goals', 'longLimit', 'saveKeep']) $(id).addEventListener('input', changed);
276
+ $('suggestBtn').addEventListener('click', suggest);
173
277
  $('previewBtn').addEventListener('click', review);
174
278
  $('saveBtn').addEventListener('click', save);
175
279
  $('form').hidden = false;
@@ -73,6 +73,13 @@
73
73
  <h2 id="goalsH"><label for="goals">Goal list</label> <span class="muted">(optional)</span> <span id="goalsHelp"></span></h2>
74
74
  <input class="setup-input" id="goals" type="text" autocomplete="off" spellcheck="false" placeholder="/path/to/your/goals.json">
75
75
  </section>
76
+ <section class="setup-part" aria-labelledby="saveToH" id="saveToPart" hidden>
77
+ <h2 id="saveToH"><label for="saveTo">Save it for</label> <span id="saveToHelp"></span></h2>
78
+ <select id="saveTo">
79
+ <option value="folder" id="saveToFolder">This folder</option>
80
+ <option value="user" id="saveToUser">Every folder</option>
81
+ </select>
82
+ </section>
76
83
  <div class="setup-inline setup-actions">
77
84
  <button type="button" class="btn" id="previewBtn">Preview</button>
78
85
  <button type="button" class="btn primary" id="saveBtn">Save</button>
@@ -24,6 +24,7 @@
24
24
  names: $('names').value,
25
25
  terms: $('terms').value,
26
26
  ...(goals ? { goalsFile: goals } : {}),
27
+ ...($('saveToPart').hidden ? {} : { saveTo: $('saveTo').value }),
27
28
  };
28
29
  }
29
30
  /** Any change makes the shown preview out of date, so it's hidden. Save is always on: the
@@ -118,6 +119,14 @@
118
119
  $('timezone').addEventListener('input', () => hc.plan());
119
120
  $('namesLabel').textContent = info.privateWords.names;
120
121
  $('termsLabel').textContent = info.privateWords.terms;
122
+ // Where to save: offered only when this run would find a user-level config from any folder.
123
+ if (info.userConfig) {
124
+ $('saveToFolder').textContent = `This folder (${info.folder})`;
125
+ $('saveToUser').textContent = `Every folder (${info.userConfig})`;
126
+ F.help($('saveToHelp'), 'where to save', ['honestweek reads the config in the folder it runs in first. A folder without one uses the every-folder config, so an agent working in any project finds it.']);
127
+ $('saveTo').addEventListener('change', changed);
128
+ $('saveToPart').hidden = false;
129
+ }
121
130
  for (const id of ['emails', 'timezone', 'names', 'terms', 'goals']) $(id).addEventListener('input', changed);
122
131
  $('previewBtn').addEventListener('click', preview);
123
132
  $('saveBtn').addEventListener('click', save);
@@ -28,7 +28,7 @@ import { dirname, join } from 'node:path';
28
28
 
29
29
  import { atomicWriteJson } from '../atomic-json.mjs';
30
30
  import { ensureGitignore } from '../init.mjs';
31
- import { cutRedacted } from './word-index.mjs';
31
+ import { cutRedacted } from '../replay/word-index.mjs';
32
32
  import { findOnPath, keepEnv, stopTree, SYSTEM_ENV } from './insights.mjs';
33
33
  import { systemProgram } from '../windows-root.mjs';
34
34