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
@@ -310,7 +310,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
310
310
  const s = ctx.sessionByKey.get(f.session);
311
311
  const label = labelOf?.(ctx, f.session) ?? null;
312
312
  // The repository is the configured label, already redacted with the rest of the history.
313
- out[f.session] = { title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, tool: s?.tool ?? null, repo: typeof s?.repo === 'string' ? s.repo : null };
313
+ out[f.session] = { title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, tool: s?.tool ?? null, repo: typeof s?.repo === 'string' ? s.repo : null, ...(s?.saved ? { saved: s.saved } : {}) };
314
314
  }
315
315
  return out;
316
316
  };
@@ -358,6 +358,26 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
358
358
  return { window: base.window, finding: redactAnswer(findingOut(ctx, hit.f, goalsOf), ctx.redact), pattern: { id: p.id, name: p.name, group: p.group, status: p.status, strength: p.strength, priority: p.priority, fix: p.draft?.title ?? p.mitigation?.[0]?.action ?? null }, catalogIds: base.patterns.map((x) => x.id), evidenceKey };
359
359
  }
360
360
 
361
+ // One pattern, whole: its catalog side and every finding in the window (not just the page's
362
+ // newest), in time order, each with the steps its check recorded described as this build
363
+ // shows them. `honestweek problems --pattern` reads it.
364
+ const one = params.get('pattern');
365
+ if (one !== null) {
366
+ const src = result.patterns.find((x) => x.id === one);
367
+ if (!src) return { status: 404, body: { error: 'No pattern in the catalog has that id.' } };
368
+ const p = base.patterns.find((x) => x.id === one);
369
+ const all = [...src.findings].sort((a, b) => String(a.at ?? '').localeCompare(String(b.at ?? '')));
370
+ const findings = all.map((f) => findingOut(ctx, f, goalsOf));
371
+ const steps = {};
372
+ for (const f of findings) {
373
+ for (const id of f.zoom?.events ?? []) {
374
+ const e = ctx.b.eventsById?.get(id);
375
+ if (e && !steps[id]) steps[id] = { at: e.at ?? null, session: e.session ?? null, kind: e.kind ?? null, evidence: e.evidence ?? null, text: describe(e) };
376
+ }
377
+ }
378
+ return { window: base.window, pattern: { ...p, findings: redactAnswer(findings, ctx.redact), findingsListed: all.length }, steps: redactAnswer(steps, ctx.redact), sessions: redactAnswer(sessionsOf(ctx, all, labelOf), ctx.redact), rules: base.rules, evidenceKey };
379
+ }
380
+
361
381
  // The trend: each pattern's findings worth a look in this window against the window just
362
382
  // before it, of the same length. The earlier window is read only for these counts, once. A
363
383
  // list, like the rules: a pattern id such as secret-exposure reads as a credential's field
@@ -367,7 +387,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
367
387
  const now = trendCounts(result);
368
388
  return Promise.resolve(earlier()).then((before) => ({
369
389
  window: base.window,
370
- ...redactAnswer({ earlier: before?.window ? { from: before.window.from, to: before.window.to, days: before.window.days, sessions: before.error ? null : before.sessions, ...(before.skipped ? { skipped: before.skipped, note: before.note } : {}), ...(before.partial ? { partial: before.partial, note: before.note } : {}), ...(before.loaded && !before.skipped ? { loaded: before.loaded, note: before.note } : {}) } : null, trend: Object.entries(trendOf(now, before)).map(([id, t]) => ({ id, ...t })) }, ctx.redact),
390
+ ...redactAnswer({ earlier: before?.window ? { from: before.window.from, to: before.window.to, days: before.window.days, sessions: before.error ? null : before.sessions, ...(before.skipped ? { skipped: before.skipped, note: before.note } : {}), ...(before.partial ? { partial: before.partial, note: before.note } : {}), ...(before.loaded && !before.skipped ? { loaded: before.loaded, note: before.note } : {}), ...(before.saved ? { saved: true } : {}) } : null, trend: Object.entries(trendOf(now, before)).map(([id, t]) => ({ id, ...t })) }, ctx.redact),
371
391
  evidenceKey,
372
392
  }));
373
393
  }
@@ -411,7 +431,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
411
431
  const s = ctx.sessionByKey.get(session);
412
432
  const all = result.patterns.flatMap((p) => p.findings.filter((f) => f.session === session));
413
433
  const label = labelOf?.(ctx, session) ?? null;
414
- focus = { session, title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, known: !!s, findings: all.length, look: all.filter((f) => f.severity === 'look').length, patterns: new Set(all.map((f) => f.pattern)).size };
434
+ focus = { session, title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, known: !!s, findings: all.length, look: all.filter((f) => f.severity === 'look').length, patterns: new Set(all.map((f) => f.pattern)).size, ...(s?.saved ? { saved: s.saved } : {}) };
415
435
  pick = (p) => newest(p.findings.filter((f) => f.session === session));
416
436
  }
417
437
  const shown = new Map(result.patterns.map((p) => [p.id, pick(p)]));
@@ -421,5 +441,11 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
421
441
  return { ...base, patterns, focus: focus ? redactAnswer(focus, ctx.redact) : null, sessions: redactAnswer(sessionsOf(ctx, all, labelOf), ctx.redact), examples: PROBLEM_EXAMPLES, evidenceKey };
422
442
  }
423
443
  problems.lookBySession = lookBySession;
444
+ /** The checks' result for `h` and each finding's key, run once and shared with every answer;
445
+ * null when the checks couldn't run. */
446
+ problems.checked = (h, builtT) => {
447
+ const c = resultFor(h, builtT);
448
+ return c.result ? { result: c.result, keys: c.keys } : null;
449
+ };
424
450
  return problems;
425
451
  }
@@ -241,7 +241,7 @@ export function exportThread(h, threadId, { focus = null, extraRules = {}, redac
241
241
  related: th.related,
242
242
  },
243
243
  session: { key: first?.key ?? null, title: first?.title ?? th.title ?? null, turns },
244
- sessions: th.sessions.map((s) => ({ key: s.key, tool: s.tool, repo: s.repo ?? null, group: sessionGroup(sessionByKey.get(s.key)), title: s.title ?? null, firstAt: s.firstAt ?? null, lastAt: s.lastAt ?? null, endState: s.endState ?? null, missing: s.missing ?? [] })),
244
+ sessions: th.sessions.map((s) => ({ key: s.key, tool: s.tool, repo: s.repo ?? null, group: sessionGroup(sessionByKey.get(s.key)), title: s.title ?? null, firstAt: s.firstAt ?? null, lastAt: s.lastAt ?? null, endState: s.endState ?? null, missing: s.missing ?? [], ...(sessionByKey.get(s.key)?.saved ? { saved: sessionByKey.get(s.key).saved } : {}) })),
245
245
  agents,
246
246
  events: events.map((e) => eventRow(h, e, agentsByKey, redact)),
247
247
  frames,
@@ -2423,12 +2423,16 @@
2423
2423
  if (!d.querySelectorAll('#repos .reporow select').length) throw new Error('no repository with a role menu');
2424
2424
  if (!/@/.test($('emails').value)) throw new Error('the emails are not shown');
2425
2425
  if (d.getElementById('wordsFold').open) throw new Error('the private words are not folded away');
2426
- // Save works from the start; with nothing changed it writes nothing, says so and stays here.
2426
+ // Save works from the start. With nothing changed it writes no config, says so and stays here.
2427
+ // It still adds the config's .gitignore line when that's missing, so it's clicked only on the
2428
+ // config this run wrote (setup's Save already ignored it): someone's own folder stays untouched.
2427
2429
  if ($('saveBtn').disabled) throw new Error('Save is off before Review changes; it should work from the start');
2428
- click($('saveBtn'));
2429
- await waitFor(() => !$('problem').hidden, { what: 'the answer to a Save with nothing changed' });
2430
- if (!onPage(doc(), 'settings.html') || text($('problem')) !== 'Nothing changed.') throw new Error(`Save with nothing changed read "${text($('problem')).slice(0, 160)}"`);
2431
- if ($('saveBtn').disabled) throw new Error('Save stays off after "Nothing changed."');
2430
+ if (env.setupDone) {
2431
+ click($('saveBtn'));
2432
+ await waitFor(() => !$('problem').hidden, { what: 'the answer to a Save with nothing changed' });
2433
+ if (!onPage(doc(), 'settings.html') || text($('problem')) !== 'Nothing changed.') throw new Error(`Save with nothing changed read "${text($('problem')).slice(0, 160)}"`);
2434
+ if ($('saveBtn').disabled) throw new Error('Save stays off after "Nothing changed."');
2435
+ }
2432
2436
  choose($('histKind'), 'days');
2433
2437
  typeInto($('histDays'), '14');
2434
2438
  // The chosen window says which days it loads, and whether that's partial; the logs line how far back they go.
@@ -320,7 +320,9 @@ export function startViewServer({ data, setup = null, settings = null, planner =
320
320
  url,
321
321
  port: boundPort,
322
322
  issueCode,
323
- address: (purpose = 'address', page = '', opts) => `${url}${page}#c=${issueCode(purpose, opts)}`,
323
+ // A page can carry its own part after # (replay's thread and step); key.js reads the
324
+ // code from among the &-separated parts there and puts the rest back.
325
+ address: (purpose = 'address', page = '', opts) => `${url}${page}${page.includes('#') ? '&' : '#'}c=${issueCode(purpose, opts)}`,
324
326
  pendingCodes: () => codes.size,
325
327
  setData: (d) => {
326
328
  current = d;
@@ -1,13 +1,14 @@
1
1
  // lib/view/settings.mjs: the Settings page's side of `honestweek view`. It changes the config
2
2
  // view started with, later, without the terminal: how far back to look, the repositories and
3
3
  // their roles, the author emails, the private words, the goal list, whether the Problems page
4
- // includes /insights, and the long-session limit it checks against.
4
+ // includes /insights, the long-session limit it checks against, and whether results are saved
5
+ // between runs and for how long (lib/saved/).
5
6
  //
6
7
  // It edits the file as it stands on disk, field by field: everything the page doesn't show,
7
8
  // and everything it shows but wasn't changed, stays exactly as it was (a repository's other
8
9
  // fields, the order of the redaction lists, curation, privacy, output). The checks are
9
- // Setup's own. A save names what changes first (preview), writes only the config in the
10
- // folder view started in, and only if the file is still the one the page was shown: a file
10
+ // Setup's own. A save names what changes first (preview), writes only the config view read,
11
+ // wherever it sits, and only if the file is still the one the page was shown: a file
11
12
  // changed meanwhile is never overwritten. Nothing is written when any check fails.
12
13
 
13
14
  import { createHash } from 'node:crypto';
@@ -16,12 +17,13 @@ import { join, resolve } from 'node:path';
16
17
 
17
18
  import { atomicWriteText } from '../atomic-json.mjs';
18
19
  import { gitExec } from '../git.mjs';
19
- import { HISTORY_LIMIT_MB, LONG_SESSION_MAX, LONG_SESSION_MIN, normalizeConfig, normalizeHistoryLimit, resolveRepoPath, ROLES } from '../config.mjs';
20
- import { authorHasCommits, checkNestedRoles, displayTest, existingDisplayRepos, findRepos, repoLastCommitAt } from '../init.mjs';
20
+ import { HISTORY_LIMIT_MB, LONG_SESSION_MAX, LONG_SESSION_MIN, normalizeConfig, normalizeHistoryLimit, resolveRepoPath, ROLES, SAVE_KEEP_DAYS } from '../config.mjs';
21
+ import { authorHasCommits, checkNestedRoles, ensureGitignore, checkoutOf, displayTest, existingDisplayRepos, findRepos, keptFromOldConfig, lookedUpConfig, nestsDisplay, reachesDisplay, repoLastCommitAt } from '../init.mjs';
21
22
  import { privateWordCount } from '../private-words.mjs';
23
+ import { SAVED_GITIGNORE } from '../saved/store.mjs';
22
24
  import { checkEmails, checkGoalsPath, checkHistory, checkRepo, checkWords, checkSameRepos, CONFIG_FILE, json, parseBody, plural, privateWordsText, refuse, roleMeanings } from './setup.mjs';
23
25
 
24
- const FIELDS = new Set(['version', 'history', 'historyLimitMB', 'repos', 'authorEmails', 'names', 'terms', 'goalsFile', 'insights', 'longSessionTokens']);
26
+ const FIELDS = new Set(['version', 'history', 'historyLimitMB', 'repos', 'authorEmails', 'names', 'terms', 'goalsFile', 'insights', 'longSessionTokens', 'saveResults']);
25
27
  const MAX_REPOS = 200;
26
28
 
27
29
  const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
@@ -106,39 +108,59 @@ function ignoreConfig(folder) {
106
108
  return true;
107
109
  }
108
110
 
109
- /** True when git tracks the config in `folder`. A folder that is, or sits inside, a display-only
110
- * repository is never asked (AGENTS.md invariant 4); no git, or no repository, reads as not tracked. */
111
- export function configTracked(folder) {
112
- if (displayTest(existingDisplayRepos(folder))(folder, { walk: true })) return false;
111
+ /** Whether git tracks the config in `folder`: 'tracked', 'untracked', or 'unchecked' when git
112
+ * isn't asked because it would reach a display-only folder (reachesDisplay; AGENTS.md invariant
113
+ * 4). Outside any checkout there's nothing for git to track, so git isn't run and it reads as
114
+ * untracked, as it does with no git. */
115
+ export function configTrackState(folder) {
116
+ if (checkoutOf(folder) === null) return 'untracked';
117
+ if (reachesDisplay(existingDisplayRepos(folder))(folder)) return 'unchecked';
113
118
  try {
114
119
  gitExec(['ls-files', '--error-unmatch', '--', CONFIG_FILE], { repo: folder, stdio: 'ignore' });
115
- return true;
120
+ return 'tracked';
116
121
  } catch {
117
- return false;
122
+ return 'untracked';
118
123
  }
119
124
  }
120
125
 
126
+ /** True when git tracks the config in `folder` (configTrackState). */
127
+ export function configTracked(folder) {
128
+ return configTrackState(folder) === 'tracked';
129
+ }
130
+
131
+ /** Settings' note when it can't ask git whether the config is committed (issue 161). */
132
+ export const CANT_CHECK_CONFIG = `Check that ${CONFIG_FILE} was never committed to git. It holds your private words, and listing it in .gitignore doesn't remove it from git if it was committed before. honestweek can't check for you here because of your display-only setting. Run git ls-files ${CONFIG_FILE}. If it prints the file name, run git rm --cached ${CONFIG_FILE}.`;
133
+
121
134
  /**
122
- * createSettings({ cwd, editable, checkGoals, history, windowFor, onSaved }) -> { info, preview, save }
135
+ * createSettings({ cwd, configDir, editable, checkGoals, history, windowFor, onSaved }) -> { info, preview, save }
136
+ * `configDir()` answers the folder of the config this run reads (the folder view started in,
137
+ * `cwd`, unless the config came from --config, HONESTWEEK_CONFIG or the user-level file); the
138
+ * file, its .gitignore and its relative paths are that folder's. Repositories to add are still
139
+ * looked for next to `cwd`.
123
140
  * `windowFor(history, limitMB)` answers which days that would load and an estimate of the cost.
124
141
  * `editable()` answers null when this run's config can be changed here, or why it can't.
125
142
  * `onSaved()` reloads the week from the new file; it throws with a plain message when it can't.
143
+ * `saved()` answers what the saved-results folder holds (lib/saved/saver.mjs), or null.
126
144
  */
127
- export function createSettings({ cwd, editable = () => null, checkGoals = () => {}, history = () => null, windowFor = () => null, onSaved = async () => ({}), lastCommitAt = repoLastCommitAt, hasCommits = authorHasCommits }) {
128
- const folder = resolve(cwd);
129
- const path = join(folder, CONFIG_FILE);
145
+ export function createSettings({ cwd, configDir = () => cwd, editable = () => null, checkGoals = () => {}, history = () => null, windowFor = () => null, onSaved = async () => ({}), saved = () => null, lastCommitAt = repoLastCommitAt, hasCommits = authorHasCommits }) {
146
+ const here = resolve(cwd);
147
+ const where = () => {
148
+ const folder = resolve(configDir() ?? cwd);
149
+ return { folder, path: join(folder, CONFIG_FILE) };
150
+ };
130
151
  const read = () => {
131
- const text = readFileSync(path, 'utf8');
152
+ const text = readFileSync(where().path, 'utf8');
132
153
  return { text, raw: JSON.parse(text), version: versionOf(text) };
133
154
  };
134
155
  const closed = () => {
135
156
  const why = editable();
136
157
  if (why) return why;
137
- if (!existsSync(path)) return `There's no ${CONFIG_FILE} in this folder yet.`;
158
+ if (!existsSync(where().path)) return `There's no ${CONFIG_FILE} in ${where().folder === here ? 'this folder' : where().folder} yet.`;
138
159
  return null;
139
160
  };
140
161
 
141
162
  function info() {
163
+ const { folder } = where();
142
164
  const why = closed();
143
165
  if (why) return { editable: false, note: why };
144
166
  let cur;
@@ -164,6 +186,8 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
164
186
  goalsFile: typeof r.goalsFile === 'string' ? r.goalsFile : '',
165
187
  insights: r.insights === true,
166
188
  longSessionTokens: Number.isInteger(r.longSessionTokens) ? r.longSessionTokens : null,
189
+ saveResults: { on: r.saveResults?.on === true, keepDays: Number.isInteger(r.saveResults?.keepDays) ? r.saveResults.keepDays : null, history: r.saveResults?.history !== false, otherSessions: r.saveResults?.otherSessions === true, defaultDays: SAVE_KEEP_DAYS.default, min: SAVE_KEEP_DAYS.min, max: SAVE_KEEP_DAYS.max },
190
+ saved: saved(),
167
191
  privateWords: privateWordsText(),
168
192
  };
169
193
  }
@@ -174,6 +198,7 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
174
198
  * the role Setup would suggest. A folder the config marks display-only is never passed to git.
175
199
  */
176
200
  function found() {
201
+ const { folder } = where();
177
202
  const why = closed();
178
203
  if (why) return { editable: false, note: why };
179
204
  let raw;
@@ -184,17 +209,27 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
184
209
  }
185
210
  const listed = displayTest((Array.isArray(raw.repos) ? raw.repos : []).filter((x) => typeof x?.path === 'string').map((x) => resolveRepoPath(x.path, folder)));
186
211
  const email = Array.isArray(raw.identity?.authorEmails) ? raw.identity.authorEmails[0] ?? null : null;
187
- const display = existingDisplayRepos(folder);
188
- // A repository holding a display-only folder, or inside one, isn't asked either: git reading
189
- // it would read that folder's history too (checkNestedRoles), and Save would refuse it.
190
- const clear = (p) => !checkNestedRoles([{ path: p, role: 'reference' }, ...display.map((d) => ({ path: d, role: 'display' }))]);
191
- const { repos } = findRepos(folder, email, { displayPaths: display, lastCommitAt: (p) => (clear(p) ? lastCommitAt(p) : null), hasCommits: (p, e) => clear(p) && hasCommits(p, e) });
192
- const fresh = repos.filter((x) => x.role !== 'display' && !listed(x.path) && clear(x.path));
212
+ // Repositories are looked for next to the folder view started in, so the display-only
213
+ // folders of a config there (or the one the lookup finds from there) are kept from git too,
214
+ // not only those of the config this run edits.
215
+ // One of those that can't be read can't say which folders it marks, so git runs nowhere.
216
+ const startKept = keptFromOldConfig(here);
217
+ const around = lookedUpConfig(here);
218
+ const blocked = startKept.unreadable ? join(here, CONFIG_FILE) : around?.unreadable ? around.path : null;
219
+ if (blocked) return { editable: false, note: `${blocked} can't be read, so Settings can't tell which folders it marks display-only, and doesn't look for repositories until it can.` };
220
+ const display = [...new Set([...existingDisplayRepos(folder), ...startKept.display.map((d) => resolveRepoPath(d.path, here)), ...(around?.display ?? [])])];
221
+ // findRepos never asks git about a repository holding a display-only folder, or inside one,
222
+ // since git reading it would read that folder's history too (nestsDisplay). It isn't
223
+ // offered either, since Save would refuse it.
224
+ const nested = nestsDisplay(display);
225
+ const { repos } = findRepos(here, email, { displayPaths: display, lastCommitAt, hasCommits });
226
+ const fresh = repos.filter((x) => x.role !== 'display' && !listed(x.path) && !nested(x.path));
193
227
  return { editable: true, repos: fresh.map((x) => ({ path: x.path, label: x.label, role: x.role, lastAt: x.lastAt ?? null })) };
194
228
  }
195
229
 
196
230
  /** The new file from the answers, and the changes in plain words, or a refusal. */
197
231
  function check(body) {
232
+ const { folder } = where();
198
233
  const why = closed();
199
234
  if (why) return { refusal: json(409, { error: 'not-editable', message: why }) };
200
235
  const b = parseBody(body, FIELDS, folder);
@@ -332,17 +367,42 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
332
367
  changes.push(v === null ? 'Long-session limit: off.' : `Long-session limit: ${v.toLocaleString('en-US')} tokens.`);
333
368
  }
334
369
 
370
+ // Saved results: { on, keepDays, history } where keepDays null is the default and history is on
371
+ // unless it's false. Off removes the field, so a file that never had it gets back the same
372
+ // bytes; on keeps a chosen keepDays, and history only when it's turned off.
373
+ if (a.saveResults !== undefined) {
374
+ const v = a.saveResults;
375
+ if (!v || typeof v !== 'object' || Array.isArray(v) || typeof v.on !== 'boolean' || Object.keys(v).some((k) => !['on', 'keepDays', 'history', 'otherSessions'].includes(k)) || ['history', 'otherSessions'].some((k) => v[k] !== undefined && typeof v[k] !== 'boolean')) return { refusal: refuse('Save results must be on or off.', 'saveResults') };
376
+ const keep = v.keepDays ?? null;
377
+ if (keep !== null && !(Number.isInteger(keep) && keep >= SAVE_KEEP_DAYS.min && keep <= SAVE_KEEP_DAYS.max)) return { refusal: refuse(`Keep saved days for a whole number of days from ${SAVE_KEEP_DAYS.min} to ${SAVE_KEEP_DAYS.max.toLocaleString('en-US')}, or leave it empty for ${SAVE_KEEP_DAYS.default}.`, 'saveResults.keepDays') };
378
+ const wasOn = was.saveResults?.on === true;
379
+ const wasKeep = Number.isInteger(was.saveResults?.keepDays) ? was.saveResults.keepDays : null;
380
+ const history = v.history !== false;
381
+ const wasHistory = was.saveResults?.history !== false;
382
+ const others = v.otherSessions === true;
383
+ const wasOthers = was.saveResults?.otherSessions === true;
384
+ if (v.on !== wasOn || (v.on && (keep !== wasKeep || history !== wasHistory || others !== wasOthers))) {
385
+ if (v.on) next.saveResults = { on: true, ...(keep !== null ? { keepDays: keep } : {}), ...(history ? {} : { history: false }), ...(others ? { otherSessions: true } : {}) };
386
+ else delete next.saveResults;
387
+ changes.push(v.on ? `Save results between runs: on, ${history ? `with each day's history${others ? ', all sessions' : ''}` : 'check results only'}, kept ${(keep ?? SAVE_KEEP_DAYS.default).toLocaleString('en-US')} days.` : 'Save results between runs: off. What was saved stays until you press Forget saved results.');
388
+ if (v.on && !gitignoreIgnores(folder, SAVED_GITIGNORE[0])) notes.push(`Saving also adds ${SAVED_GITIGNORE[0]} to .gitignore.`);
389
+ }
390
+ }
391
+
335
392
  try {
336
393
  normalizeConfig(next, { configDir: folder });
337
394
  } catch (err) {
338
395
  return { refusal: refuse(String(err?.message ?? err)) };
339
396
  }
340
- if (privateWordCount(next) > 0) {
341
- if (configTracked(folder)) notes.push(`${CONFIG_FILE} is tracked by git, so .gitignore won't hide it. It lists private words: run git rm --cached ${CONFIG_FILE} if it shouldn't be committed.`);
342
- // The same check save() makes before it adds the line, so the note matches what Save does.
343
- else if (!gitignoreIgnores(folder, CONFIG_FILE)) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it lists private words.`);
344
- }
345
- return { next, changes, notes, cur };
397
+ // A tracked config is worth a warning once it lists private words, and where git can't be
398
+ // asked, so is checking by hand. Save adds the .gitignore line either way, and this makes the
399
+ // same check it does, so the note matches what Save does.
400
+ // Save repeats the warning (`warning`), since it can be pressed without Review.
401
+ const trackState = privateWordCount(next) > 0 ? configTrackState(folder) : null;
402
+ const warning = trackState === 'tracked' ? `${CONFIG_FILE} is tracked by git, so .gitignore won't hide it. It lists private words: run git rm --cached ${CONFIG_FILE} if it shouldn't be committed.` : trackState === 'unchecked' ? CANT_CHECK_CONFIG : null;
403
+ if (warning) notes.push(warning);
404
+ if (trackState !== 'tracked' && !gitignoreIgnores(folder, CONFIG_FILE)) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it holds your email, folder paths and any private words.`);
405
+ return { next, changes, notes, cur, warning };
346
406
  }
347
407
 
348
408
  function preview(body) {
@@ -352,16 +412,24 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
352
412
  }
353
413
 
354
414
  async function save(body) {
415
+ const { folder, path } = where();
355
416
  const r = check(body);
356
417
  if (r.refusal) return r.refusal;
357
- if (!r.changes.length) return json(200, { saved: false, changes: [], message: 'Nothing changed.' });
418
+ if (!r.changes.length) {
419
+ // Save always adds the .gitignore line (Preview's note says so), so a save with nothing else to change still does.
420
+ const ignored = ignoreConfig(folder);
421
+ const message = ignored ? `Nothing in the config changed. ${CONFIG_FILE} is now in .gitignore.` : 'Nothing changed.';
422
+ return json(200, { saved: false, changes: [], message: r.warning ? `${message} ${r.warning}` : message });
423
+ }
358
424
  atomicWriteText(path, serializeLike(r.next, r.cur.text));
359
- if (privateWordCount(r.next) > 0) ignoreConfig(folder);
425
+ ignoreConfig(folder);
426
+ if (r.next.saveResults?.on === true) ensureGitignore(folder, SAVED_GITIGNORE[0]);
427
+ const warning = r.warning ? { warning: r.warning } : {};
360
428
  try {
361
429
  await onSaved();
362
- return json(200, { saved: true, changes: r.changes, next: 'problems.html' });
430
+ return json(200, { saved: true, changes: r.changes, next: 'problems.html', ...warning });
363
431
  } catch (err) {
364
- return json(200, { saved: true, changes: r.changes, restart: `${String(err?.message ?? err).replace(/^view: /, '')} Your settings are saved; stop honestweek view with Ctrl+C and start it again.` });
432
+ return json(200, { saved: true, changes: r.changes, ...warning, restart: `${String(err?.message ?? err).replace(/^view: /, '')} Your settings are saved; stop honestweek view with Ctrl+C and start it again.` });
365
433
  }
366
434
  }
367
435
 
@@ -7,16 +7,18 @@
7
7
  // page sends its answers to this program only, in a POST body; nothing here logs them, puts
8
8
  // them in an address, or writes them anywhere but the config file.
9
9
  //
10
- // Every write lands in the folder the command was started in, which no request can change.
11
- // A config already there, or one that appears before saving, means nothing is written. A
10
+ // Every write lands in the folder the command was started in, or, when the person picks it, in
11
+ // the user-level folder (~/.honestweek), which a command run from any folder finds. No request
12
+ // can name another folder. A config already there, or one that appears before saving, means
13
+ // nothing is written. A
12
14
  // repository marked display is never passed to git: discovery skips any the folder's config
13
15
  // already lists that way, and a repository added by path is only looked at on disk.
14
16
 
15
- import { existsSync, statSync } from 'node:fs';
16
- import { basename, join, resolve } from 'node:path';
17
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
18
+ import { basename, dirname, join, resolve } from 'node:path';
17
19
 
18
20
  import { hostTimezone, isEmailShaped, normalizeConfig, normalizeHistory, resolveRepoPath, ROLES } from '../config.mjs';
19
- import { buildConfig, checkNestedRoles, displayTest, findRepos, foundLine, inferIdentity, isGitRepo, repoIdentity, NAMES_QUESTION, parseWordList, PRIVATE_WORDS_INTRO, ROLE_MEANINGS, TERMS_QUESTION, writeInitFiles } from '../init.mjs';
21
+ import { buildConfig, checkNestedRoles, displayTest, findRepos, foundLine, gitignoreHas, inferIdentity, isGitRepo, repoIdentity, NAMES_QUESTION, parseWordList, PRIVATE_WORDS_INTRO, ROLE_MEANINGS, TERMS_QUESTION, writeInitFiles } from '../init.mjs';
20
22
  import { privateWordCount } from '../private-words.mjs';
21
23
 
22
24
  export const CONFIG_FILE = 'honestweek.config.json';
@@ -24,7 +26,7 @@ export const CONFIG_FILE = 'honestweek.config.json';
24
26
  export const SETUP_MAX_BODY = 64 * 1024;
25
27
  /** The history choice Setup starts on: the last week, as view has always read. */
26
28
  export const DEFAULT_HISTORY = Object.freeze({ days: 7 });
27
- const FIELDS = new Set(['authorEmails', 'timezone', 'repos', 'names', 'terms', 'goalsFile', 'history']);
29
+ const FIELDS = new Set(['authorEmails', 'timezone', 'repos', 'names', 'terms', 'goalsFile', 'history', 'saveTo']);
28
30
  const REPO_FIELDS = new Set(['path', 'label', 'role']);
29
31
  const MAX_REPOS = 200;
30
32
  const MAX_EMAILS = 20;
@@ -171,14 +173,18 @@ export function checkHistory(v) {
171
173
  }
172
174
 
173
175
  /**
174
- * createSetup({ cwd, command, inferEmail, checkGoals, history, onSaved }) -> { info, preview, save, pending }
176
+ * createSetup({ cwd, command, inferEmail, checkGoals, history, onSaved, userConfig, configFound }) -> { info, preview, save, pending }
177
+ * `userConfig` is the user-level config file the page may save to instead of this folder (null
178
+ * when this run wouldn't look there). `configFound()` answers whether a config this run would read
179
+ * is there now, from Setup or from init in another terminal (this folder's, by default).
175
180
  * `checkGoals(path)` throws with a plain message when a goal list can't be read.
176
181
  * `history()` describes the logs on this machine for the "how far back" choice.
177
182
  * `onSaved()` loads the new config and starts the week; it answers { next } or throws.
178
183
  */
179
- export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoals = () => {}, history = () => null, onSaved = async () => ({}) }) {
184
+ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoals = () => {}, history = () => null, onSaved = async () => ({}), userConfig = null, configFound }) {
180
185
  const folder = resolve(cwd);
181
186
  const configPath = join(folder, CONFIG_FILE);
187
+ const userDir = userConfig ? dirname(resolve(userConfig)) : null;
182
188
  let found = null;
183
189
  let saved = false;
184
190
 
@@ -191,7 +197,7 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
191
197
  return found;
192
198
  }
193
199
 
194
- const pending = () => !saved && !existsSync(configPath);
200
+ const pending = () => !saved && !(configFound ? configFound() : existsSync(configPath));
195
201
 
196
202
  function info() {
197
203
  if (!pending()) return { configured: true, folder, configFile: CONFIG_FILE, note: `${CONFIG_FILE} is already in this folder.` };
@@ -200,6 +206,7 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
200
206
  configured: false,
201
207
  folder,
202
208
  configFile: CONFIG_FILE,
209
+ ...(userConfig ? { userConfig: resolve(userConfig) } : {}),
203
210
  command,
204
211
  found: p.repos.length ? foundLine(p.repos, p.folded) : 'No git repositories found nearby. Add one by its folder path.',
205
212
  repos: p.repos,
@@ -218,6 +225,8 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
218
225
  const b = parseBody(body, FIELDS, folder);
219
226
  if (b.refusal) return b;
220
227
  const raw = b.value;
228
+ if (raw.saveTo !== undefined && raw.saveTo !== 'folder' && !(raw.saveTo === 'user' && userDir)) return { refusal: refuse(userDir ? 'Save it in this folder or for every folder.' : 'This run saves in this folder only.', 'saveTo') };
229
+ const target = raw.saveTo === 'user' ? userDir : folder;
221
230
  const emails = checkEmails(raw.authorEmails);
222
231
  if (emails.refusal) return emails;
223
232
  if (typeof raw.timezone !== 'string' || raw.timezone.length > 64 || !validTimezone(raw.timezone)) return { refusal: refuse(`${JSON.stringify(String(raw.timezone ?? '').slice(0, 64))} isn't a timezone this machine knows. Use an IANA name such as Europe/Paris or UTC.`, 'timezone') };
@@ -251,8 +260,10 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
251
260
  } catch (err) {
252
261
  return { refusal: refuse(String(err?.message ?? err)) };
253
262
  }
254
- if (privateWordCount(config) > 0) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it lists private words.`);
255
- return { config, notes };
263
+ // Save adds the line unless .gitignore already has it (init's ensureGitignore makes the same check).
264
+ if (!gitignoreHas(target, CONFIG_FILE)) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it holds your email, folder paths and any private words.`);
265
+ if (target !== folder) notes.push(`It's saved as ${join(target, CONFIG_FILE)}, which honestweek reads from any folder without its own config.`);
266
+ return { config, notes, target };
256
267
  }
257
268
 
258
269
  function preview(body) {
@@ -268,12 +279,14 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
268
279
  propose();
269
280
  const r = check(body);
270
281
  if (r.refusal) return r.refusal;
271
- const result = writeInitFiles(folder, r.config, { onlyNew: true });
272
- if (!result.wrote.includes(CONFIG_FILE)) return json(409, { error: 'exists', message: `${CONFIG_FILE} appeared in this folder while saving, so nothing was written or changed. Reload the page to see your week.` });
282
+ // The user-level folder is made owner-only on POSIX, like the config in it.
283
+ if (r.target !== folder) mkdirSync(r.target, { recursive: true, mode: 0o700 });
284
+ const result = writeInitFiles(r.target, r.config, { onlyNew: true, example: r.target === folder });
285
+ if (!result.wrote.includes(CONFIG_FILE)) return json(409, { error: 'exists', message: `${CONFIG_FILE} appeared in ${r.target === folder ? 'this folder' : r.target} while saving, so nothing was written or changed. Reload the page to see your week.` });
273
286
  saved = true;
274
287
  const wrote = result.wrote.filter((w) => !w.startsWith('.gitignore ('));
275
288
  const ignored = result.wrote.length - wrote.length;
276
- const written = `Wrote ${wrote.join(' and ')}${ignored ? ` and ${plural(ignored, '.gitignore line')}` : ''}.`;
289
+ const written = `Wrote ${wrote.join(' and ')}${ignored ? ` and ${plural(ignored, '.gitignore line')}` : ''}${r.target !== folder ? ` in ${r.target}` : ''}.`;
277
290
  try {
278
291
  const next = await onSaved();
279
292
  return json(200, { saved: true, written, next: next?.next ?? 'problems.html' });
@@ -0,0 +1,67 @@
1
+ // lib/view/suggest-words.mjs: words in my own prompts and session titles that look like names,
2
+ // for Settings to offer as private words (issue 165).
3
+ //
4
+ // It reads the redacted build, so a word honestweek already hides never shows, and only the
5
+ // sessions in my configured repositories, the ones every lookup and goal uses. It writes nothing:
6
+ // the page shows the words, and nothing changes until I add some and press Save.
7
+
8
+ import { isNameShaped } from '../harvest.mjs';
9
+
10
+ /** Words common in coding sessions that look like names but aren't private. */
11
+ const TOOL_WORDS = new Set(
12
+ (
13
+ 'claude codex github gitlab bitbucket readme changelog license api cli sdk mcp npm pnpm npx yarn node deno bun ' +
14
+ 'json yaml toml html css url urls http https ssh tls jwt oauth sql csv pdf png svg ui ux ci cd pr prs ' +
15
+ 'windows linux macos ubuntu debian chrome firefox safari vscode cursor typescript javascript python rust golang java ' +
16
+ 'markdown docker kubernetes aws gcp azure honestweek ok todo fixme'
17
+ ).split(/\s+/)
18
+ );
19
+
20
+ /** At most this many suggestions, so the list stays short. */
21
+ export const MAX_SUGGESTIONS = 20;
22
+
23
+ /** A capitalized word that only ever starts a sentence is usually just that, so it counts only
24
+ * where it doesn't: mid-sentence. A sentence also starts a line, a list item ("- Check",
25
+ * "2. Update"), and a quote or bracket opening one. CamelCase and all-capitals words count
26
+ * anywhere. */
27
+ const startsSentence = (text, at) =>
28
+ /(?:^|[.!?:;]["'”’)\]]*\s+|\n)\s*(?:(?:[-*+>#]+|\d+[.)])\s+)?["'“‘([]*$/u.test(text.slice(Math.max(0, at - 16), at));
29
+
30
+ /**
31
+ * suggestWords({ sessions, events, include, exclude }) -> [{ word, count }]
32
+ * Name-shaped words in any alphabet (a capitalized word mid-sentence, CamelCase starting with a
33
+ * capital, or all capitals), outside code, in the titles and prompts of the sessions `include`
34
+ * keeps, seen at least twice, most frequent first, at most MAX_SUGGESTIONS. `exclude` holds
35
+ * whole words to leave out, any case: listed private words and the parts of repository labels.
36
+ * A listed name of two words leaves its parts in, since the redactor hides only the whole name
37
+ * and a first name alone still shows.
38
+ */
39
+ export function suggestWords({ sessions, events, include, exclude = [], promptText = (e) => e.facts?.text }) {
40
+ const skip = new Set([...TOOL_WORDS, ...exclude.map((w) => String(w).trim().toLowerCase()).filter(Boolean)]);
41
+ const counts = new Map();
42
+ const add = (raw) => {
43
+ // Code isn't a name: leave out code blocks and `code`, an identifier with an underscore
44
+ // (GITHUB_TOKEN), and a name starting lowercase (filterSince).
45
+ const text = String(raw).replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
46
+ for (const m of text.matchAll(/\p{L}[\p{L}\p{N}_]+/gu)) {
47
+ const w = m[0];
48
+ if (w.includes('_') || /^\p{Ll}/u.test(w)) continue;
49
+ if (!isNameShaped(w) || skip.has(w.toLowerCase())) continue;
50
+ if (/^\p{Lu}\p{Ll}+$/u.test(w) && startsSentence(text, m.index)) continue;
51
+ counts.set(w, (counts.get(w) ?? 0) + 1);
52
+ }
53
+ };
54
+ const kept = sessions.filter(include);
55
+ const keys = new Set(kept.map((s) => s.key));
56
+ for (const s of kept) if (typeof s.title === 'string') add(s.title);
57
+ for (const e of events) {
58
+ if (e.kind !== 'prompt' || !keys.has(e.session)) continue;
59
+ const text = promptText(e);
60
+ if (typeof text === 'string') add(text);
61
+ }
62
+ return [...counts]
63
+ .filter(([, n]) => n >= 2)
64
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
65
+ .slice(0, MAX_SUGGESTIONS)
66
+ .map(([word, count]) => ({ word, count }));
67
+ }