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.mjs CHANGED
@@ -8,34 +8,37 @@
8
8
  // file is deleted once the page has traded its code for the run's key, after
9
9
  // OPENER_TTL_MS when it hasn't (its code stops working then too), and on exit.
10
10
  //
11
- // Nothing is published and nothing it reads is written to disk. With --demo it builds
11
+ // Nothing is published, and it keeps only what the user chooses to save (the config, Run with
12
+ // Codex's answers). With --demo it builds
12
13
  // the made-up demo week in a temporary folder, serves only that, and deletes it on exit.
13
14
 
14
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15
16
  import { tmpdir } from 'node:os';
16
- import { dirname, isAbsolute, join, resolve } from 'node:path';
17
+ import { basename, dirname, join } from 'node:path';
17
18
  import { createInterface } from 'node:readline';
18
19
 
19
- import { HISTORY_LIMIT_MB, hostTimezone, loadConfig } from './config.mjs';
20
+ import { hostTimezone } from './config.mjs';
21
+ import { findConfig, lookupUserConfig, noteConfig } from './config-lookup.mjs';
20
22
  import { currentCommand, pageCommand } from './invocation.mjs';
21
23
  import { buildDemoWeek, markDemoOwner, sweepStaleDemoDirs } from './demo/week.mjs';
22
24
  import { defaultOpener } from './preview.mjs';
23
- import { normalizeGoalRecord } from './replay/goals.mjs';
24
25
  import { defaultRoots } from './replay/sources.mjs';
25
- import { localDateInTimezone } from './resolve-week.mjs';
26
26
  import { createViewData, privateWordCount, privateWordsNote } from './view/data.mjs';
27
27
  import { CODE_TTL_MS, startViewServer } from './view/server.mjs';
28
28
  import { createSetup } from './view/setup.mjs';
29
29
  import { createInsights, insightsDir } from './view/insights.mjs';
30
+ import { pageLink } from './view/page-link.mjs';
30
31
  import { createCodexJudge } from './view/codex-judge.mjs';
31
32
  import { createSettings, saveInsightsFlag } from './view/settings.mjs';
32
- import { bytesIn, fitsInMemory, historyInfo, logFiles, memoryRoom, paramsOfHistory, planWindow, sizeText, WHOLE_DAYS, windowAnswer } from './view/window.mjs';
33
+ import { bytesIn, fitsInMemory, historyInfo, logFiles, memoryRoom, paramsOfHistory, sizeText, WHOLE_DAYS, windowAnswer } from './view/window.mjs';
33
34
  import { createProgressiveData, fitWindow } from './view/progressive.mjs';
35
+ import { createSaver } from './saved/saver.mjs';
36
+ import { absolute, limitOf, ownWeek, readGoalList, resolveViewWindow, setup, SetupError, spanDays, validTimezone } from './view/own-week.mjs';
37
+ import { DEMO_TERM } from './demo/week.mjs';
38
+
39
+ export { DEMO_TERM, ownWeek, readGoalList, resolveViewWindow };
34
40
 
35
41
  const CONFIG_FILE = 'honestweek.config.json';
36
- const DEFAULT_DAYS = 7;
37
- /** The made-up project name the demo hides as a private word, so the switch shows something. */
38
- export const DEMO_TERM = 'lantern';
39
42
  /** Where --self-test serves its click-through page. */
40
43
  export const SELF_TEST_PAGE = 'selftest/clickthrough.html';
41
44
  /** The page a folder with no config opens on. */
@@ -48,23 +51,25 @@ export const OPENER_TTL_MS = 2 * 60 * 1000;
48
51
  export const HELP = `honestweek view: find, check and replay your agent work in your browser.
49
52
 
50
53
  Usage:
51
- honestweek view [--days <n> | --from <YYYY-MM-DD> --to <YYYY-MM-DD>]
54
+ ${currentCommand()} view [--days <n> | --from <YYYY-MM-DD> --to <YYYY-MM-DD>]
52
55
  [--goals <file>] [--config <file>] [--timezone <zone>]
53
56
  [--port <n>] [--no-open] [--self-test]
54
- honestweek view --demo [--port <n>] [--no-open] [--self-test]
57
+ ${currentCommand()} view --demo [--port <n>] [--no-open] [--self-test]
55
58
 
56
59
  Reads your config and the last 7 days of your Claude Code and Codex logs, then
57
- serves a page on 127.0.0.1 and opens your browser. With no
58
- honestweek.config.json in this folder, the page that opens is Setup: it finds
59
- your repositories, email and timezone, asks which names and client words to
60
- keep private, shows the config, and saves it, then goes on to your week. For
61
- scripts and CI, honestweek init asks the same questions in a terminal. With
60
+ serves a page on 127.0.0.1 and opens your browser. The config is the
61
+ honestweek.config.json in this folder, else the file HONESTWEEK_CONFIG names,
62
+ else ~/.honestweek/honestweek.config.json. With none of those, the page that
63
+ opens is Setup: it finds your repositories, email and timezone, asks which
64
+ names and client words to keep private, shows the config, and saves it, in
65
+ this folder or for every folder, then goes on to your week. For
66
+ scripts and CI, ${currentCommand()} init asks the same questions in a terminal. With
62
67
  no --days, --from or --to, it reads as far back as the config's "history"
63
68
  says (Setup and the Settings page set it), or the last 7 days. Type a pull request, a commit,
64
69
  a file, a branch or some words to find the sessions and goals behind it, and
65
70
  replay any session step by step. Every step and link says how it's known:
66
71
  recorded, derived, inferred, missing, or ambiguous. Nothing is published, nothing
67
- leaves your machine, and nothing it reads is written to disk.
72
+ leaves your machine, and you have full control over what it keeps.
68
73
 
69
74
  The page shows redacted text. Its Show private text switch shows names, client
70
75
  words and folders on your own screen; keys, tokens and passwords stay hidden.
@@ -74,7 +79,8 @@ config, so until you list some, they show as written, and view says so.
74
79
  Each run makes a fresh key. The address it opens and prints carries a one-time
75
80
  code instead, which the page trades for that key, so only a page you opened can
76
81
  read your data. A printed address works once, within ${CODE_TTL_MS / 60_000} minutes. Press Enter
77
- here to print a fresh address. Ctrl+C stops it.
82
+ here to print a fresh address, or type link and a page, such as
83
+ link replay.html?session=<id>, for a fresh one-time address to that page. Ctrl+C stops it.
78
84
 
79
85
  Options:
80
86
  --days <n> Look at the last n days (default 7).
@@ -84,9 +90,13 @@ Options:
84
90
  config's week.timezone).
85
91
  --goals <file> Your goal list: a JSON file of goals and their changes.
86
92
  You can set "goalsFile" in the config instead.
87
- --config <file> Read this config instead of ./honestweek.config.json.
93
+ --config <file> Read this config instead of the one honestweek finds.
88
94
  --port <n> Serve on this port (default: a free one).
89
95
  --no-open Don't open a browser; just print the address.
96
+ --page <page> Open on this page instead of Problems, for example
97
+ "replay.html?session=<id>" or one step of it,
98
+ "replay.html?session=<id>#<thread>~<step>". Only view's
99
+ own pages are accepted.
90
100
  --demo Look around a made-up week instead of your own logs. It
91
101
  uses its own logs, config, goal list and week, so it
92
102
  can't be combined with --config, --goals, --days,
@@ -98,17 +108,14 @@ Options:
98
108
  -h, --help Show this help.
99
109
  `;
100
110
 
101
- const VALUED = new Set(['--config', '--days', '--from', '--to', '--timezone', '--goals', '--port']);
111
+ const VALUED = new Set(['--config', '--days', '--from', '--to', '--timezone', '--goals', '--port', '--page']);
102
112
  const BARE = new Set(['--no-open', '--demo', '--self-test', '--help', '-h']);
103
113
  const DEMO_REFUSES = ['--config', '--goals', '--days', '--from', '--to', '--timezone'];
104
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
105
114
 
106
115
  function defaultIo() {
107
116
  return { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) };
108
117
  }
109
118
 
110
- class SetupError extends Error {}
111
- const setup = (message) => new SetupError(message);
112
119
 
113
120
  /** Parse argv into { flags, values }; an unknown option or a missing value throws. */
114
121
  export function parseViewArgs(argv) {
@@ -140,97 +147,6 @@ export function parseViewPort(value) {
140
147
  return Number(value);
141
148
  }
142
149
 
143
- 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;
144
- const spanDays = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000) + 1;
145
- const shiftDay = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
146
-
147
- function validTimezone(tz) {
148
- try {
149
- new Intl.DateTimeFormat('en-US', { timeZone: tz });
150
- return true;
151
- } catch {
152
- return false;
153
- }
154
- }
155
-
156
- /** The window to read: --from/--to, or the last --days days (default 7) ending today. */
157
- export function resolveViewWindow(values, timezone, now = Date.now()) {
158
- const hasRange = '--from' in values || '--to' in values;
159
- if (hasRange && '--days' in values) throw setup('view: use --days, or --from with --to, not both.');
160
- if (hasRange) {
161
- const from = values['--from'];
162
- const to = values['--to'];
163
- if (from === undefined || to === undefined) throw setup('view: --from and --to go together. Give both, or use --days.');
164
- 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)}).`);
165
- if (from > to) throw setup(`view: --from (${from}) is after --to (${to}).`);
166
- return { from, to };
167
- }
168
- let days = DEFAULT_DAYS;
169
- if ('--days' in values) {
170
- const v = values['--days'];
171
- 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)}).`);
172
- days = Number(v);
173
- }
174
- const today = localDateInTimezone(new Date(now), timezone).toISOString().slice(0, 10);
175
- return { from: shiftDay(today, -(days - 1)), to: today };
176
- }
177
-
178
- /**
179
- * readGoalList(path) -> the parsed goal list. Throws a setup error with a plain message
180
- * when the file is missing, can't be read as JSON, is the goals page's registry, or doesn't fit.
181
- */
182
- export function readGoalList(path) {
183
- 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.`);
184
- let parsed;
185
- try {
186
- parsed = JSON.parse(readFileSync(path, 'utf8'));
187
- } catch {
188
- // Never the parser's message: it quotes the file's first characters, and this check takes
189
- // any path. A folder or an unreadable file reads the same as a file that isn't JSON.
190
- throw setup(`view: ${path} isn't a goal list (not valid JSON).`);
191
- }
192
- const looksLikeRegistry = parsed && typeof parsed === 'object' && !Array.isArray(parsed.goals) && (parsed.objectives !== undefined || parsed.projectToObjective !== undefined);
193
- if (looksLikeRegistry) {
194
- 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.`);
195
- }
196
- try {
197
- normalizeGoalRecord(parsed);
198
- } catch (err) {
199
- throw setup(`view: ${path} isn't a goal list: ${String(err?.message ?? err).replace(/^replay: /, '')}`);
200
- }
201
- return parsed;
202
- }
203
-
204
- /** The config's limit on log data a saved choice loads, in MB. */
205
- const limitOf = (config) => config?.historyLimitMB ?? HISTORY_LIMIT_MB.default;
206
-
207
- const absolute = (cwd, p) => (isAbsolute(p) ? p : resolve(cwd, p));
208
-
209
- /** The person's own week: the config, the window and the goal list the options and the config
210
- * name. Throws a setup error with a plain message when one of them can't be read. */
211
- function ownWeek({ values, cwd, env, now }) {
212
- const configPath = values['--config'] ? absolute(cwd, values['--config']) : join(cwd, CONFIG_FILE);
213
- 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.`);
214
- let config;
215
- try {
216
- config = loadConfig(configPath);
217
- } catch (err) {
218
- throw setup(`view: ${err.message}`);
219
- }
220
- const timezone = values['--timezone'] ?? config.week?.timezone;
221
- 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.`);
222
- const roots = defaultRoots(env);
223
- // The flags win for one run. With none, a saved choice is sized to fit (the last week, or up to
224
- // 7 days, always whole); with no saved choice either, it's the last 7 days, as it always was.
225
- // Either way a window of more than one day loads its newest day first.
226
- const flagged = ['--days', '--from', '--to'].some((f) => f in values);
227
- const plan = !flagged && config.history ? planWindow(config.history, { roots, timezone, now: now(), maxBytes: limitOf(config) * 1024 * 1024 }) : null;
228
- const { from, to } = plan ?? resolveViewWindow(values, timezone, now());
229
- const goalsPath = values['--goals'] ? absolute(cwd, values['--goals']) : config.goalsFile ?? null;
230
- const goalRecord = goalsPath ? readGoalList(goalsPath) : null;
231
- return { config, configPath, roots, from, to, timezone, goalRecord, windowNote: plan?.note ?? null };
232
- }
233
-
234
150
  /** What the data routes answer while setup isn't done: every page but Setup is sent there.
235
151
  * `appeared()` answers the week's data once a config turns up in the folder, whoever wrote it
236
152
  * (Setup, or init in another terminal), and null until then. */
@@ -295,6 +211,12 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
295
211
  const demo = flags.has('--demo');
296
212
  const selfTest = flags.has('--self-test');
297
213
  const noOpen = flags.has('--no-open');
214
+ // --page: the page the opened and printed addresses go to, checked before anything is read.
215
+ const startAt = values['--page'] === undefined ? null : pageLink(values['--page']);
216
+ if (startAt?.error) {
217
+ io.err(`view: --page: ${startAt.error}\n`);
218
+ return 1;
219
+ }
298
220
 
299
221
  const cleanups = [];
300
222
  const cleanup = () => {
@@ -336,13 +258,14 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
336
258
  timezone: d.week.timezone,
337
259
  goalRecord: d.goalRecord,
338
260
  };
339
- } else if (!values['--config'] && !existsSync(join(cwd, CONFIG_FILE))) {
261
+ } else if (findConfig({ cwd, flag: values['--config'] }).source === 'none') {
340
262
  // No config here: the Setup page writes one. The options that don't need a config are
341
263
  // checked now, so a typo shows before setup rather than after it.
342
264
  if ('--timezone' in values && !validTimezone(values['--timezone'])) throw setup(`view: ${JSON.stringify(values['--timezone'])} isn't a timezone this machine knows. Use an IANA name such as Europe/Paris or UTC.`);
343
265
  resolveViewWindow(values, values['--timezone'] ?? 'UTC', now());
344
266
  if (values['--goals']) readGoalList(absolute(cwd, values['--goals']));
345
267
  } else {
268
+ noteConfig(io.err, 'view', findConfig({ cwd, flag: values['--config'] }), { cwd, writes: true });
346
269
  setupData = ownWeek({ values, cwd, env, now });
347
270
  }
348
271
  } catch (err) {
@@ -355,9 +278,13 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
355
278
  }
356
279
 
357
280
  const command = pageCommand(currentCommand());
358
- // Settings can't change a config named with --config outside this folder; the /insights
359
- // toggle follows the same rule, and is kept for this run only when it can't be saved.
360
- const elsewhere = () => !!values['--config'] && resolve(absolute(cwd, values['--config'])) !== resolve(join(cwd, CONFIG_FILE));
281
+ // Settings changes the config this run reads, in its own folder, which may not be this one
282
+ // (--config, HONESTWEEK_CONFIG, the user-level file). It writes only a file called
283
+ // honestweek.config.json, so a config named with --config under another name stays as it is;
284
+ // the /insights toggle follows the same rule, and is kept for this run only when it can't be saved.
285
+ const configDir = () => (setupData?.configPath ? dirname(setupData.configPath) : cwd);
286
+ const fileName = (p) => (process.platform === 'win32' ? basename(p).toLowerCase() : basename(p));
287
+ const elsewhere = () => !!setupData?.configPath && fileName(setupData.configPath) !== CONFIG_FILE;
361
288
  // The Problems page's optional /insights group and its Run button: one for the whole run, so
362
289
  // a run still going survives a Settings save.
363
290
  const insights = createInsights({
@@ -368,9 +295,9 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
368
295
  cwd,
369
296
  persist: async (on) => {
370
297
  if (demo) return { remembered: false, note: 'For this run only: the demo has no config to save.' };
371
- if (elsewhere()) return { remembered: false, note: 'For this run only: the config named with --config is outside this folder.' };
372
- if (!existsSync(join(cwd, CONFIG_FILE))) throw new Error('finish setup first');
373
- return saveInsightsFlag(cwd, on);
298
+ if (elsewhere()) return { remembered: false, note: `For this run only: the config named with --config isn't called ${CONFIG_FILE}, so it can't be changed here.` };
299
+ if (!existsSync(join(configDir(), CONFIG_FILE))) throw new Error('finish setup first');
300
+ return saveInsightsFlag(configDir(), on);
374
301
  },
375
302
  });
376
303
  cleanups.push(() => insights.stop());
@@ -379,6 +306,8 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
379
306
  // It runs only while Include /insights is on, the switch the page shows its button under.
380
307
  const codexJudge = createCodexJudge({ configDir: () => (setupData?.configPath ? dirname(setupData.configPath) : null), demo, env, isOn: () => insights.isOn() });
381
308
  cleanups.push(() => codexJudge.stop());
309
+ // Saved results (lib/saved/): only with "saveResults" on, in a folder beside the config view read.
310
+ const saver = createSaver({ configDir: () => (setupData?.configPath ? dirname(setupData.configPath) : null), config: () => setupData?.config ?? null, demo, now });
382
311
  // Checks that keep a large window from running the process out of memory, and Show private
383
312
  // text's second copy only when it fits beside the first. The window before (for the Problems
384
313
  // page's trend) follows the window's own rule: for a week or less, all of it, newest day first,
@@ -386,7 +315,7 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
386
315
  const guards = (d) => ({
387
316
  earlierProgressive: spanDays(d.from, d.to) <= WHOLE_DAYS,
388
317
  earlierCheck: (w) => {
389
- const files = logFiles(d.roots, d.timezone);
318
+ const files = d.files ?? logFiles(d.roots, d.timezone);
390
319
  const bytes = bytesIn({ files, from: w.from, to: w.to });
391
320
  const limit = limitOf(d.config);
392
321
  if (spanDays(d.from, d.to) > WHOLE_DAYS && bytes > limit * 1024 * 1024) return { why: 'limit', note: `The ${w.days} days before hold ${sizeText(bytes)} of logs, past the ${limit} MB limit, so they aren't compared.` };
@@ -401,11 +330,11 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
401
330
  },
402
331
  });
403
332
  const weekData = (d) => {
404
- const base = { ...d, demo, selfTest, now, command, insights, codexJudge, ...(buildHistory ? { buildHistory } : {}) };
333
+ const base = { ...d, demo, selfTest, now, command, insights, codexJudge, settingsOpen: !elsewhere(), ...(buildHistory ? { buildHistory } : {}), ...(d.config?.saveResults?.on === true ? { onChecked: saver.onChecked, savedLoad: (w) => saver.load({ ...w, roots: d.roots }), savedCounts: (w) => saver.counts({ ...w, roots: d.roots }) } : {}) };
405
334
  // The demo week is small and fixed, so it loads at once, as it always has.
406
335
  const made = demo
407
336
  ? createViewData(base)
408
- : createProgressiveData({ from: d.from, to: d.to, timezone: d.timezone, roots: d.roots, make: (o) => createViewData({ ...base, ...o, ...guards({ ...d, from: o.from, to: o.to }) }) });
337
+ : createProgressiveData({ from: d.from, to: d.to, timezone: d.timezone, roots: d.roots, ...(d.files ? { files: d.files } : {}), make: (o) => createViewData({ ...base, ...o, ...guards({ ...d, from: o.from, to: o.to }) }) });
409
338
  made.start();
410
339
  return made;
411
340
  };
@@ -416,7 +345,7 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
416
345
  let data = setupData
417
346
  ? weekData(setupData)
418
347
  : setupPendingData(command, async () => {
419
- if (!existsSync(join(cwd, CONFIG_FILE))) return null;
348
+ if (findConfig({ cwd, flag: values['--config'] }).source === 'none') return null;
420
349
  await adopt().catch(() => {});
421
350
  return data;
422
351
  });
@@ -449,24 +378,27 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
449
378
  return { next: 'problems.html' };
450
379
  };
451
380
  // Setup mode: the Setup page writes the config with init's functions, then the week loads.
452
- const setupFlow = setupData ? null : createSetup({ cwd, command, inferEmail, checkGoals: readGoalList, history: logs, onSaved: adopt });
453
- // Settings changes this folder's config later. It's closed for the demo, during setup, and
454
- // for a config named with --config, which may sit outside the folder view started in.
381
+ // It can save for every folder when this run would read the user-level file.
382
+ const setupFlow = setupData ? null : createSetup({ cwd, command, inferEmail, checkGoals: readGoalList, history: logs, onSaved: adopt, userConfig: lookupUserConfig(), configFound: () => findConfig({ cwd, flag: values['--config'] }).source !== 'none' });
383
+ // Settings changes the config later. It's closed for the demo, during setup, and for a config
384
+ // named with --config under another name.
455
385
  const settingsFlow = demo
456
386
  ? null
457
387
  : createSettings({
458
388
  cwd,
389
+ configDir,
459
390
  checkGoals: readGoalList,
460
391
  history: logs,
461
392
  windowFor: (h, limit) => planner(paramsOfHistory(h, limit)).body,
462
393
  onSaved: reload,
394
+ saved: () => saver.info(),
463
395
  editable: () => {
464
396
  if (setupFlow?.pending()) return 'Finish setup first.';
465
- if (elsewhere()) return `This run reads a config named with --config, so Settings can't change it here. Run view in that config's folder without --config.`;
397
+ if (elsewhere()) return `This run reads a config named with --config that isn't called ${CONFIG_FILE}, so Settings can't change it here.`;
466
398
  return null;
467
399
  },
468
400
  });
469
- const homePage = () => (setupFlow?.pending() ? SETUP_PAGE : '');
401
+ const homePage = () => (setupFlow?.pending() ? SETUP_PAGE : (startAt?.page ?? ''));
470
402
 
471
403
  let openerDir = null;
472
404
  const removeOpener = () => {
@@ -478,9 +410,12 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
478
410
  ['/api/insights/toggle', (body) => insights.toggle(body)],
479
411
  ['/api/insights/run', () => insights.run()],
480
412
  ['/api/insights/codex-run', () => codexJudge.run(data.codexWork?.() ?? null)],
413
+ ['/api/saved/forget', () => saver.forget()],
481
414
  ]);
482
415
  handle = await startViewServer({ data, setup: setupFlow, settings: settingsFlow, planner: demo ? null : planner, actions, port, selfTest, onClaim: (purpose) => purpose === 'opener' && removeOpener(), ...(codeClock ? { now: codeClock } : {}) });
483
416
  } catch (err) {
417
+ // The week's build has already started: it stops too, so a run that ends here reads no git.
418
+ data.stop?.();
484
419
  cleanup();
485
420
  io.err(err?.code === 'EADDRINUSE' ? `view: port ${port} is already in use. Leave --port out to pick a free one, or choose another.\n` : `view: couldn't start the page server (${err?.message ?? err}).\n`);
486
421
  return 1;
@@ -491,6 +426,8 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
491
426
  const stop = async () => {
492
427
  if (stopped) return;
493
428
  stopped = true;
429
+ // A build still reading stops too, so a stopped page runs no more git.
430
+ data.stop?.();
494
431
  await handle.close();
495
432
  cleanup();
496
433
  };
@@ -517,7 +454,7 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
517
454
  if (setupFlow) io.out(`honestweek view: there's no ${CONFIG_FILE} in ${cwd}, so setup is open in your browser at ${handle.url} (Ctrl+C stops it). For scripts and CI, ${currentCommand()} init still works.\n`);
518
455
  else io.out(`honestweek view: serving ${demo ? 'the made-up demo week' : `${setupData.from} to ${setupData.to}`} at ${handle.url} (Ctrl+C stops it).\n`);
519
456
  if (setupData?.windowNote) io.out(`${setupData.windowNote}\n`);
520
- if (!demo && setupData && privateWordCount(setupData.config) === 0) io.out(`${privateWordsNote(currentCommand())}\n`);
457
+ if (!demo && setupData && privateWordCount(setupData.config) === 0) io.out(`${privateWordsNote(currentCommand(), { settings: !elsewhere() })}\n`);
521
458
  if (!noOpen) {
522
459
  try {
523
460
  openerDir = mkdtempSync(join(tmpdir(), 'honestweek-view-'));
@@ -542,13 +479,25 @@ export async function runView({ argv = [], cwd = process.cwd(), env = process.en
542
479
  // A printed address can sit in terminal scrollback, so its code lasts printedTtlMs, not until used.
543
480
  const printed = (page) => handle.address('printed', page, { ttlMs: printedTtlMs });
544
481
  io.out(`Open this address in your browser: ${printed(homePage())}\n`);
482
+ // --page waits for Setup, which goes to Problems once saved, so say how to reach the page after it.
483
+ if (startAt?.page && setupFlow?.pending()) io.out(`Setup comes first. Once it's saved, type link ${startAt.page} here for an address to that page.\n`);
545
484
  if (selfTest) io.out(`The click-through test: ${printed(SELF_TEST_PAGE)}\n`);
546
485
  io.out(`Each address works once, within ${lifetime(printedTtlMs)}. Press Enter here to print a fresh one.\n`);
547
486
 
548
487
  let rl = null;
549
488
  if (input && typeof input.on === 'function') {
550
489
  rl = createInterface({ input, terminal: false });
551
- rl.on('line', () => {
490
+ rl.on('line', (line) => {
491
+ // "link <page>": a fresh one-time address to that page (issue 198). Anything else, Enter
492
+ // included, prints a fresh address as before.
493
+ const asked = /^\s*link\s+(\S.*)$/.exec(String(line ?? ''));
494
+ if (asked) {
495
+ const target = pageLink(asked[1]);
496
+ if (target.error) io.out(`No link: ${target.error}\n`);
497
+ else if (setupFlow?.pending()) io.out('No link yet: finish Setup first, then ask again.\n');
498
+ else io.out(`Link: ${printed(target.page)}\n`);
499
+ return;
500
+ }
552
501
  io.out(`Fresh address: ${printed(homePage())}\n`);
553
502
  if (selfTest) io.out(`Fresh click-through test: ${printed(SELF_TEST_PAGE)}\n`);
554
503
  });
package/lib/worktrees.mjs CHANGED
@@ -157,6 +157,35 @@ function sameFolder(a, b) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * The folders of a repository's linked worktrees, as git recorded them: each
162
+ * `<commonDir>/worktrees/<name>/gitdir` holds the path of that worktree's own `.git`
163
+ * FILE, and the worktree is its parent folder. A worktree whose folder is gone keeps
164
+ * its recorded path until git prunes it. Empty when there are none or they can't be
165
+ * read. With `withLocks`, each is { path, locked }, locked when `git worktree lock` kept
166
+ * it from pruning. Reads files; never runs git.
167
+ */
168
+ export function linkedWorkTrees(commonDir, { withLocks = false } = {}) {
169
+ const worktreesDir = join(commonDir, 'worktrees');
170
+ if (!existsSync(worktreesDir)) return [];
171
+ let entries;
172
+ try {
173
+ entries = readdirSync(worktreesDir, { withFileTypes: true });
174
+ } catch {
175
+ return [];
176
+ }
177
+ const out = [];
178
+ for (const e of entries) {
179
+ if (!e.isDirectory()) continue;
180
+ const pointer = readPointer(join(worktreesDir, e.name, 'gitdir'));
181
+ if (!pointer) continue;
182
+ const gitFile = isAbsolute(pointer) ? pointer : resolve(worktreesDir, e.name, pointer);
183
+ const path = dirname(gitFile);
184
+ out.push(withLocks ? { path, locked: existsSync(join(worktreesDir, e.name, 'locked')) } : path);
185
+ }
186
+ return out;
187
+ }
188
+
160
189
  /**
161
190
  * resolveWorkTrees(root) -> string[]
162
191
  *
@@ -211,24 +240,8 @@ export function resolveWorkTrees(root) {
211
240
  // repository's common dir is the repo itself (not named ".git"), and has none.
212
241
  if (basename(normalize(commonDir)) === '.git') addEvery(dirname(commonDir));
213
242
 
214
- const worktreesDir = join(commonDir, 'worktrees');
215
- if (!existsSync(worktreesDir)) return out;
216
- let entries;
217
- try {
218
- entries = readdirSync(worktreesDir, { withFileTypes: true });
219
- } catch {
220
- return out;
221
- }
222
- for (const e of entries) {
223
- if (!e.isDirectory()) continue;
224
- // `worktrees/<name>/gitdir` holds the absolute path of that worktree's own
225
- // `.git` FILE; the worktree root is its parent directory. A pruned/stale entry
226
- // simply yields a path that matches no session cwd — harmless.
227
- const pointer = readPointer(join(worktreesDir, e.name, 'gitdir'));
228
- if (!pointer) continue;
229
- const gitFile = isAbsolute(pointer) ? pointer : resolve(worktreesDir, e.name, pointer);
230
- addEvery(dirname(gitFile));
231
- }
243
+ // A pruned/stale entry simply yields a path that matches no session cwd — harmless.
244
+ for (const p of linkedWorkTrees(commonDir)) addEvery(p);
232
245
  } catch {
233
246
  // Unreadable or malformed git metadata: attribution degrades to the given path.
234
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honestweek",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Find, replay and check your Claude Code and Codex sessions on your own machine, and turn a week of them into an honest, git-checked summary.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "bin/",
29
29
  "lib/",
30
30
  "SKILL.md",
31
+ "flows/",
31
32
  "honestweek.config.example.json",
32
33
  ".claude-plugin/"
33
34
  ],