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/build.mjs CHANGED
@@ -16,6 +16,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
16
16
  import { basename, dirname, join, resolve } from 'node:path';
17
17
 
18
18
  import { loadConfig } from './config.mjs';
19
+ import { commandConfig } from './config-lookup.mjs';
19
20
  import { citedShas, itemRepoLabel, verifyItems, repoMetricsInWindow, commitMessage } from './git.mjs';
20
21
  import { loadReaderProfile, ReaderProfileError, READER_FILE } from './reader.mjs';
21
22
  import { renderNote } from './emit/client.mjs';
@@ -249,14 +250,22 @@ function normalizeWeek(week, now, config) {
249
250
  * On a verification failure it calls io.exit(2) after a clear message and writes
250
251
  * nothing. io.exit defaults to process.exit; tests inject a throwing exit.
251
252
  */
252
- export async function runBuild({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), promptLockHeld = false, transactionFs = {} } = {}) {
253
+ export async function runBuild({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), promptLockHeld = false, transactionFs = {}, found } = {}) {
253
254
  if (!promptLockHeld) {
255
+ // The config can sit elsewhere (--config, HONESTWEEK_CONFIG, the user-level file); every file
256
+ // build reads or writes then sits beside it, never in an unrelated folder this ran in.
257
+ const opened = commandConfig({ command: 'build', cwd, argv, err: io.err });
258
+ if (opened.error) {
259
+ io.err(`build: ${opened.error}\n`);
260
+ return io.exit(1) ?? 1;
261
+ }
262
+ ({ cwd, argv } = opened);
254
263
  let code;
255
264
  try {
256
265
  assertNoDigestPending(cwd);
257
266
  code = await withPromptLock(
258
267
  cwd,
259
- () => runBuild({ cwd, argv, now, io: { out: io.out, err: io.err, exit: (value) => value }, promptLockHeld: true, transactionFs }),
268
+ () => runBuild({ cwd, argv, now, io: { out: io.out, err: io.err, exit: (value) => value }, promptLockHeld: true, transactionFs, found: opened.found }),
260
269
  { ensureIgnored: false },
261
270
  );
262
271
  } catch (err) {
@@ -265,7 +274,7 @@ export async function runBuild({ cwd = process.cwd(), argv = [], now = new Date(
265
274
  }
266
275
  return code === 0 ? 0 : (io.exit(code) ?? code);
267
276
  }
268
- const configPath = join(cwd, CONFIG_FILE);
277
+ const configPath = found?.path ?? join(cwd, CONFIG_FILE);
269
278
  const itemsPath = join(cwd, ITEMS_FILE);
270
279
 
271
280
  let config;
@@ -0,0 +1,179 @@
1
+ // lib/config-lookup.mjs: which honestweek.config.json a command reads, wherever it runs.
2
+ //
3
+ // Claude Code and Codex keep sessions for the whole machine, not per folder, so an agent working
4
+ // in some other project still wants the config the person set up. The lookup, in order:
5
+ //
6
+ // 1. --config <file>, when the command was given one;
7
+ // 2. honestweek.config.json in the folder the command runs in, so every existing setup (and a
8
+ // scheduled run) reads exactly what it always read;
9
+ // 3. the file the HONESTWEEK_CONFIG environment variable names;
10
+ // 4. the user-level file, ~/.honestweek/honestweek.config.json.
11
+ //
12
+ // Steps 3 and 4 are on only when the entry point (bin/honestweek.mjs) turns them on. A library
13
+ // caller that hands a command its own `cwd`, such as a scheduled run or a test, reads that folder's
14
+ // config and nothing else, as it always has.
15
+ //
16
+ // A command that writes files (the draft, the items, the sidecars, the output) writes them beside
17
+ // the config it read, never into an unrelated folder it happened to run in: `dir` is that folder.
18
+ //
19
+ // Zero runtime dependencies: Node built-ins only.
20
+
21
+ import { existsSync, lstatSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join, resolve } from 'node:path';
24
+
25
+ export const CONFIG_FILE = 'honestweek.config.json';
26
+ export const CONFIG_ENV = 'HONESTWEEK_CONFIG';
27
+ /** The user-level folder, under the home folder. */
28
+ export const USER_DIR = '.honestweek';
29
+
30
+ /** The user-level config file for `home` (the home folder by default). */
31
+ export function userConfigPath(home = homedir()) {
32
+ return join(home, USER_DIR, CONFIG_FILE);
33
+ }
34
+
35
+ let machine = null;
36
+
37
+ /**
38
+ * Turn the machine-wide steps (HONESTWEEK_CONFIG, then the user-level file) on for this process,
39
+ * and the one stderr line that names the config each command read. The entry point calls this
40
+ * once; `null` turns them off again (tests).
41
+ */
42
+ export function setConfigLookup(context = {}) {
43
+ machine = context ? { env: context.env ?? process.env, home: context.home ?? homedir() } : null;
44
+ }
45
+
46
+ /** The user-level config file this process would read, or null when the lookup is off. */
47
+ export function lookupUserConfig() {
48
+ return machine ? userConfigPath(machine.home) : null;
49
+ }
50
+
51
+ /** Whether anything is at `path`, a broken link included: a config here that can't be read is an
52
+ * error to report, never a reason to read another one. */
53
+ const present = (path) => {
54
+ try {
55
+ lstatSync(path);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ };
61
+
62
+ const same = (a, b) => {
63
+ const x = resolve(a);
64
+ const y = resolve(b);
65
+ return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
66
+ };
67
+
68
+ /** The config's folder, kept exactly as the caller gave `cwd` when it's the same folder, so a
69
+ * message that prints the folder prints what it always printed. */
70
+ const folderOf = (path, cwd) => (same(dirname(path), cwd) ? cwd : dirname(path));
71
+
72
+ /**
73
+ * findConfig({ cwd, flag, lookup }) -> { path, dir, source, exists }
74
+ *
75
+ * `source` says which step answered: 'flag' (--config), 'folder' (the folder it runs in), 'env'
76
+ * (HONESTWEEK_CONFIG), 'user' (the user-level file), or 'none' (nowhere; `path` is then this
77
+ * folder's, where today's messages say it's missing). A step that names a file stops the lookup
78
+ * even when the file isn't there, so a typo in --config or HONESTWEEK_CONFIG is an error, never a
79
+ * quiet fall through to another config. `dir` is the folder a writing command writes in.
80
+ */
81
+ export function findConfig({ cwd = process.cwd(), flag, lookup = machine } = {}) {
82
+ if (typeof flag === 'string' && flag) {
83
+ const path = resolve(cwd, flag);
84
+ return { path, dir: folderOf(path, cwd), source: 'flag', exists: existsSync(path) };
85
+ }
86
+ const here = join(cwd, CONFIG_FILE);
87
+ if (present(here)) return { path: here, dir: cwd, source: 'folder', exists: true };
88
+ if (lookup) {
89
+ const named = lookup.env?.[CONFIG_ENV];
90
+ if (typeof named === 'string' && named.trim()) {
91
+ const path = resolve(cwd, named.trim());
92
+ return { path, dir: folderOf(path, cwd), source: 'env', exists: existsSync(path) };
93
+ }
94
+ const user = userConfigPath(lookup.home);
95
+ if (present(user)) return { path: user, dir: folderOf(user, cwd), source: 'user', exists: true };
96
+ }
97
+ return { path: here, dir: cwd, source: 'none', exists: false };
98
+ }
99
+
100
+ /**
101
+ * takeConfigFlag(argv) -> { config, argv, error }
102
+ * Takes `--config <file>` (or `--config=<file>`) out of `argv`, so each command's own parser sees
103
+ * only its own options. `error` is a plain message for a missing value or a repeat.
104
+ */
105
+ export function takeConfigFlag(argv = []) {
106
+ const rest = [];
107
+ let config;
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const a = argv[i];
110
+ let value;
111
+ if (a === '--config') {
112
+ value = argv[i + 1];
113
+ if (value === undefined || value.startsWith('--')) return { config, argv, error: '--config needs a file.' };
114
+ i += 1;
115
+ } else if (typeof a === 'string' && a.startsWith('--config=')) {
116
+ value = a.slice('--config='.length);
117
+ if (!value) return { config, argv, error: '--config needs a file.' };
118
+ } else {
119
+ rest.push(a);
120
+ continue;
121
+ }
122
+ if (config !== undefined) return { config, argv, error: '--config is given twice.' };
123
+ config = value;
124
+ }
125
+ return { config, argv: rest };
126
+ }
127
+
128
+ /** `--config <file>` as it was given, for a printed next command to repeat; none without one. */
129
+ export function configAgain(config) {
130
+ if (config === undefined) return [];
131
+ return ['--config', /\s/.test(config) ? `"${config}"` : config];
132
+ }
133
+
134
+ const WHY = {
135
+ flag: 'named with --config',
136
+ folder: 'in this folder',
137
+ env: `named by ${CONFIG_ENV}`,
138
+ user: 'your user-level config',
139
+ };
140
+
141
+ /** How a config was found, in words ('in this folder'); null for none. */
142
+ export function configSourceWords(source) {
143
+ return WHY[source] ?? null;
144
+ }
145
+
146
+ /** The one line that names the config a command read, and where its files go when that isn't the
147
+ * folder it runs in. '' when no config was found (the command's own message says so). */
148
+ export function configLine(command, found, { cwd = process.cwd(), writes = false } = {}) {
149
+ if (!found || found.source === 'none') return '';
150
+ const away = writes && !same(found.dir, cwd) ? ` Files it writes go in ${resolve(found.dir)}.` : '';
151
+ return `honestweek ${command}: config ${found.path} (${WHY[found.source]}).${away}\n`;
152
+ }
153
+
154
+ /** Write configLine to `err` when the entry point turned the lookup on. */
155
+ export function noteConfig(err, command, found, options) {
156
+ if (!machine) return;
157
+ const line = configLine(command, found, options);
158
+ if (line) err(line);
159
+ }
160
+
161
+ /**
162
+ * commandConfig({ command, cwd, argv, err, writes }) -> { found, cwd, argv } or { error }
163
+ * The lookup as a command uses it: takes --config out of `argv`, finds the config, names it on
164
+ * stderr, and answers the folder the command works in from here on (the config's own).
165
+ */
166
+ export function commandConfig({ command, cwd = process.cwd(), argv = [], err = () => {}, writes = true }) {
167
+ const taken = takeConfigFlag(argv);
168
+ if (taken.error) return { error: taken.error };
169
+ const found = findConfig({ cwd, flag: taken.config });
170
+ noteConfig(err, command, found, { cwd, writes });
171
+ return { found, cwd: found.dir, argv: taken.argv };
172
+ }
173
+
174
+ /** What a missing config looks like for `found`, in one line that names the fix. */
175
+ export function missingConfig(found, command = 'honestweek') {
176
+ if (found.source === 'flag') return `there's no config at ${found.path}, the file --config names.`;
177
+ if (found.source === 'env') return `there's no config at ${found.path}, the file ${CONFIG_ENV} names.`;
178
+ return `there's no ${CONFIG_FILE} here, no ${CONFIG_ENV}, and no user-level config. Run ${command} init (add --user to set one up for every folder), or ${command} view to set one up in your browser.`;
179
+ }
package/lib/config.mjs CHANGED
@@ -407,6 +407,10 @@ export function normalizeConfig(raw, { configDir = process.cwd() } = {}) {
407
407
  fail(`"longSessionTokens" must be a whole number from ${LONG_SESSION_MIN} to ${LONG_SESSION_MAX} when present.`);
408
408
  }
409
409
 
410
+ // --- saveResults (optional): whether view keeps its results between runs, in a folder beside
411
+ // this file, and for how long (issue 151). Absent or off, nothing is saved or read.
412
+ const saveResults = raw.saveResults === undefined ? undefined : normalizeSaveResults(raw.saveResults);
413
+
410
414
  return {
411
415
  identity: { authorEmails: authorEmails.map(String) },
412
416
  week: { startsOn, timezone },
@@ -423,9 +427,25 @@ export function normalizeConfig(raw, { configDir = process.cwd() } = {}) {
423
427
  ...(historyLimitMB !== undefined ? { historyLimitMB } : {}),
424
428
  ...(raw.insights === true ? { insights: true } : {}),
425
429
  ...(raw.longSessionTokens !== undefined ? { longSessionTokens: raw.longSessionTokens } : {}),
430
+ ...(saveResults !== undefined ? { saveResults } : {}),
426
431
  };
427
432
  }
428
433
 
434
+ /** How long a saved day may be kept, in days (lib/saved/store.mjs reads the same numbers). */
435
+ export const SAVE_KEEP_DAYS = Object.freeze({ min: 1, max: 36500, default: 365 });
436
+
437
+ /** { on, keepDays?, history?, otherSessions? }: on, history and otherSessions are true or false,
438
+ * and keepDays a whole number of days in range. */
439
+ export function normalizeSaveResults(value) {
440
+ const msg = `"saveResults" must be { "on": true or false, "keepDays": n, "history": true or false, "otherSessions": true or false } with n from ${SAVE_KEEP_DAYS.min} to ${SAVE_KEEP_DAYS.max} (all but "on" may be left out).`;
441
+ if (!isPlainObject(value)) fail(msg);
442
+ if (Object.keys(value).some((k) => !['on', 'keepDays', 'history', 'otherSessions'].includes(k))) fail(msg);
443
+ for (const k of ['history', 'otherSessions']) if (value[k] !== undefined && typeof value[k] !== 'boolean') fail(msg);
444
+ if (typeof value.on !== 'boolean') fail(msg);
445
+ if (value.keepDays !== undefined && !(Number.isInteger(value.keepDays) && value.keepDays >= SAVE_KEEP_DAYS.min && value.keepDays <= SAVE_KEEP_DAYS.max)) fail(msg);
446
+ return { on: value.on, ...(value.keepDays !== undefined ? { keepDays: value.keepDays } : {}), ...(value.history !== undefined ? { history: value.history } : {}), ...(value.otherSessions !== undefined ? { otherSessions: value.otherSessions } : {}) };
447
+ }
448
+
429
449
  /** The range a long-session limit may take, in tokens of context. */
430
450
  export const LONG_SESSION_MIN = 10_000;
431
451
  export const LONG_SESSION_MAX = 10_000_000;
package/lib/demo/week.mjs CHANGED
@@ -25,6 +25,9 @@ import { ciLog, FIXTURES, parseChangelog, repoOf, tapReport, toMarkdown } from '
25
25
  import { extraWeek } from './extra.mjs';
26
26
  import { createRepo } from './repo.mjs';
27
27
 
28
+ /** The made-up project name the demo hides as a private word, so the switch shows something. */
29
+ export const DEMO_TERM = 'lantern';
30
+
28
31
  export const ME = 'you@example.com';
29
32
  const AUTHOR = 'You';
30
33
  export const WEEK = Object.freeze({ from: '2025-03-10', to: '2025-03-16', timezone: 'UTC' });
@@ -12,6 +12,7 @@ import {
12
12
  retiredRow, subjectFingerprint, validateLifecycleWeek, validateRetired, validDateKey, validIso,
13
13
  } from './digest-lifecycle.mjs';
14
14
  import { assessPublicRendition, createRedactor } from './redact.mjs';
15
+ import { currentCommand } from './invocation.mjs';
15
16
 
16
17
  export const CARRY_STORE = 'honestweek.carry.json';
17
18
  export const CARRY_PENDING = 'honestweek.carry.pending.json';
@@ -329,7 +330,7 @@ export function removeCarryPending(cwd, fs = { unlinkSync }) {
329
330
 
330
331
  export function assertNoCarryPending(cwd) {
331
332
  if (existsSync(join(cwd, CARRY_PENDING))) {
332
- throw new Error(`${CARRY_PENDING} is present; run honestweek digest recover before continuing.`);
333
+ throw new Error(`${CARRY_PENDING} is present; run ${currentCommand()} digest recover before continuing.`);
333
334
  }
334
335
  }
335
336
 
@@ -367,7 +368,7 @@ export function recoverCarryPending({ cwd, config, outputPath, outputBinding, pe
367
368
  return { recovered: true, action: 'completed' };
368
369
  }
369
370
  if (!outputTarget && carryPrior) {
370
- throw new Error('carry pending has no matching output; run honestweek digest recover --discard-pending after verifying the prior output.');
371
+ throw new Error(`carry pending has no matching output; run ${currentCommand()} digest recover --discard-pending after verifying the prior output.`);
371
372
  }
372
373
  throw new Error('carry pending hashes are in an unknown state; no state was changed.');
373
374
  }
@@ -9,6 +9,7 @@ import {
9
9
  } from './digest-schema.mjs';
10
10
  import { digestItemIdentity, sha256 } from './prompt-identity.mjs';
11
11
  import { DETECTOR_ORDER, REPLACEABLE_DETECTORS } from './prompt-privacy.mjs';
12
+ import { currentCommand } from './invocation.mjs';
12
13
  import {
13
14
  addWeeks, exactObject as exact, validateLifecycleEntry, validateLifecycleWeek,
14
15
  validateRenewal, validateRetired, validIso,
@@ -392,7 +393,7 @@ export function validateDigestLane(lane, config) {
392
393
 
393
394
  export function readDigestReview(cwd, config, { historical = false } = {}) {
394
395
  const path = join(cwd, DIGEST_STORE);
395
- if (!existsSync(path)) throw new Error(`${DIGEST_STORE} not found; run honestweek digest prepare.`);
396
+ if (!existsSync(path)) throw new Error(`${DIGEST_STORE} not found; run ${currentCommand()} digest prepare.`);
396
397
  let value;
397
398
  try { value = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { throw new Error(`${DIGEST_STORE} is not valid JSON (${error.message}).`); }
398
399
  return validateDigestReview(value, config, { historical });
@@ -443,7 +444,7 @@ export function readDigestPending(cwd, { optional = false } = {}) {
443
444
  }
444
445
 
445
446
  export function assertNoDigestPending(cwd) {
446
- if (existsSync(join(cwd, DIGEST_PENDING))) throw new Error(`${DIGEST_PENDING} is present; run honestweek digest prepare to recover before any other command.`);
447
+ if (existsSync(join(cwd, DIGEST_PENDING))) throw new Error(`${DIGEST_PENDING} is present; run ${currentCommand()} digest prepare to recover before any other command.`);
447
448
  }
448
449
 
449
450
  export function assertRecoverableDigestPending(cwd, pending) {
package/lib/digest.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { loadConfig } from './config.mjs';
5
+ import { commandConfig, configAgain, takeConfigFlag } from './config-lookup.mjs';
5
6
  import { localDateInTimezone, localDateRangeInstants, resolveWeek } from './resolve-week.mjs';
6
7
  import { resolvePromptRoots, scanPromptSources } from './prompt-adapters.mjs';
7
8
  import { mergePromptStore, PROMPT_GITIGNORE, readPromptStore, writePromptStore } from './prompt-store.mjs';
@@ -24,6 +25,7 @@ import {
24
25
  import { addWeeks } from './digest-lifecycle.mjs';
25
26
  import { recoverConfiguredCarry } from './carry-recovery.mjs';
26
27
  import { verifyCarryReceipts } from './carry-receipts.mjs';
28
+ import { currentCommand } from './invocation.mjs';
27
29
 
28
30
  function defaultIo() {
29
31
  return { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s), exit: (code) => process.exit(code) };
@@ -59,7 +61,7 @@ function nextPage(args, offset) {
59
61
  if (args[i] === '--offset') { i += 1; continue; }
60
62
  kept.push(args[i]);
61
63
  }
62
- return `honestweek digest candidates${kept.length ? ` ${kept.join(' ')}` : ''} --offset ${offset}`;
64
+ return `${currentCommand()} digest candidates${kept.length ? ` ${kept.join(' ')}` : ''} --offset ${offset}`;
63
65
  }
64
66
 
65
67
  function positionalArgs(args, optionsWithValues) {
@@ -119,7 +121,7 @@ async function prepare({ cwd, config, args, now, roots, io, transactionFs = {},
119
121
  phase = 'pending removal';
120
122
  removeDigestPending(cwd, transactionFs.remove);
121
123
  } catch (error) {
122
- throw new Error(`digest transaction remains pending after the ${phase} write failed (${error.message}); that state may have changed, so rerun honestweek digest prepare.`);
124
+ throw new Error(`digest transaction remains pending after the ${phase} write failed (${error.message}); that state may have changed, so rerun ${currentCommand()} digest prepare.`);
123
125
  }
124
126
  const omitted = DIGEST_CATEGORIES.map((category) => {
125
127
  const counts = lane.withheld.byCategory[category];
@@ -137,7 +139,7 @@ async function loadCanonical({ cwd, config, args, now, roots }) {
137
139
  cwd, config, week: { start: week.start, end: week.end }, now, roots,
138
140
  hasGoals: existsSync(join(cwd, 'honestweek.objectives.json')),
139
141
  });
140
- if (loaded.lane?.version !== 2) throw new Error('balanced digest is not prepared; run honestweek digest prepare.');
142
+ if (loaded.lane?.version !== 2) throw new Error(`balanced digest is not prepared; run ${currentCommand()} digest prepare.`);
141
143
  return { review: loaded.review, lane: loaded.lane, week };
142
144
  }
143
145
 
@@ -148,7 +150,7 @@ function normalizeCategory(value) {
148
150
  return value;
149
151
  }
150
152
 
151
- async function candidates({ cwd, config, args, now, roots, io }) {
153
+ async function candidates({ cwd, config, args, now, roots, io, again = [] }) {
152
154
  const { review } = await loadCanonical({ cwd, config, args, now, roots });
153
155
  const category = normalizeCategory(flag(args, '--category'));
154
156
  const decision = flag(args, '--decision') ?? 'all';
@@ -170,7 +172,7 @@ async function candidates({ cwd, config, args, now, roots, io }) {
170
172
  }
171
173
  const shown = Math.min(limit, Math.max(0, rows.length - offset));
172
174
  const remaining = Math.max(0, rows.length - offset - shown);
173
- io.out(`digest candidates: showing ${shown ? offset + 1 : 0}-${shown ? offset + shown : 0} of ${rows.length}; remaining ${remaining}.${remaining ? ` Next: ${nextPage(args, offset + shown)}.` : ''}\n`);
175
+ io.out(`digest candidates: showing ${shown ? offset + 1 : 0}-${shown ? offset + shown : 0} of ${rows.length}; remaining ${remaining}.${remaining ? ` Next: ${nextPage([...args, ...again], offset + shown)}.` : ''}\n`);
174
176
  return 0;
175
177
  }
176
178
 
@@ -264,7 +266,7 @@ async function control({ cwd, config, command, args, now, roots, io, transaction
264
266
  const count = review.candidates.length;
265
267
  await prepare({ cwd, config, args, now, roots, io, transactionFs,
266
268
  priorReviewOverride: bulkDeletedReview(review, now), quiet: true });
267
- io.out(`digest delete: removed ${count} private digest item(s) and left no-text tombstones. This cannot recall honestweek.prompt-items.json or the built page. Run honestweek validate, then honestweek build; remove the local output now if needed.\n`);
269
+ io.out(`digest delete: removed ${count} private digest item(s) and left no-text tombstones. This cannot recall honestweek.prompt-items.json or the built page. Run ${currentCommand()} validate, then ${currentCommand()} build; remove the local output now if needed.\n`);
268
270
  return 0;
269
271
  }
270
272
  const [prefix] = positions;
@@ -279,12 +281,12 @@ async function control({ cwd, config, command, args, now, roots, io, transaction
279
281
  : controlledReview(review, candidate, command, now);
280
282
  await prepare({ cwd, config, args, now, roots, io, transactionFs, priorReviewOverride: override, quiet: true });
281
283
  if (command === 'carry-forward') {
282
- io.out(`digest carry-forward: ${candidate.itemRef.slice(0, 12)} is renewed for ${addWeeks(review.week.start, 1)} only. Renewal cannot override receipts or privacy. Run honestweek validate, then honestweek build.\n`);
284
+ io.out(`digest carry-forward: ${candidate.itemRef.slice(0, 12)} is renewed for ${addWeeks(review.week.start, 1)} only. Renewal cannot override receipts or privacy. Run ${currentCommand()} validate, then ${currentCommand()} build.\n`);
283
285
  } else if (command === 'delete') {
284
- io.out(`digest delete: removed private ${candidate.category} item ${candidate.itemRef.slice(0, 12)} and left a no-text tombstone. This cannot recall honestweek.prompt-items.json or the built page. Run honestweek validate, then honestweek build; remove the local output now if needed.\n`);
286
+ io.out(`digest delete: removed private ${candidate.category} item ${candidate.itemRef.slice(0, 12)} and left a no-text tombstone. This cannot recall honestweek.prompt-items.json or the built page. Run ${currentCommand()} validate, then ${currentCommand()} build; remove the local output now if needed.\n`);
285
287
  } else {
286
288
  const next = readDigestReview(cwd, config).candidates.find((value) => value.itemRef === candidate.itemRef);
287
- io.out(`digest ${command}: ${candidate.itemRef.slice(0, 12)} is ${command === 'keep' ? 'kept' : 'hidden'}; decision ${next.decision}. ${command === 'keep' ? 'Keep cannot override receipt or privacy gates. ' : ''}Run honestweek validate, then honestweek build.\n`);
289
+ io.out(`digest ${command}: ${candidate.itemRef.slice(0, 12)} is ${command === 'keep' ? 'kept' : 'hidden'}; decision ${next.decision}. ${command === 'keep' ? 'Keep cannot override receipt or privacy gates. ' : ''}Run ${currentCommand()} validate, then ${currentCommand()} build.\n`);
288
290
  }
289
291
  return 0;
290
292
  }
@@ -378,7 +380,7 @@ async function resetTombstones({ cwd, config, args, now, io, transactionFs = {}
378
380
  next.generatedAt = now.toISOString();
379
381
  writePromptStore(cwd, next, transactionFs.resetPromptStore);
380
382
  }
381
- io.out(`digest reset-tombstones: removed ${carryRemoved + reviewRemoved} digest blocker(s) and ${promptRemoved} prompt blocker(s). No live text, public lane, or output was changed. Reset cannot recall or repair an already built, copied, or published artifact. Run honestweek digest prepare, honestweek validate, then honestweek build.\n`);
383
+ io.out(`digest reset-tombstones: removed ${carryRemoved + reviewRemoved} digest blocker(s) and ${promptRemoved} prompt blocker(s). No live text, public lane, or output was changed. Reset cannot recall or repair an already built, copied, or published artifact. Run ${currentCommand()} digest prepare, ${currentCommand()} validate, then ${currentCommand()} build.\n`);
382
384
  return 0;
383
385
  }
384
386
 
@@ -394,15 +396,20 @@ async function recover({ cwd, config, args, io, transactionFs = {}, quiet = fals
394
396
  }
395
397
 
396
398
  export async function runDigest({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), roots, transactionFs } = {}) {
397
- const [command, ...args] = argv;
399
+ const taken = takeConfigFlag(argv);
400
+ const [command, ...args] = taken.argv;
398
401
  // Help anywhere in argv, not just first: `digest prepare --help` used to run
399
402
  // a real prepare (loading config, taking the lock, scanning transcripts).
400
403
  if (!command || argv.some((a) => a === '-h' || a === '--help')) {
401
- io.out('Usage: honestweek digest <prepare|candidates|explain|keep|hide|delete|carry-forward|recover|reset-tombstones> [options]\n');
404
+ io.out(`Usage: ${currentCommand()} digest <prepare|candidates|explain|keep|hide|delete|carry-forward|recover|reset-tombstones> [options] [--config <file>]\n`);
402
405
  return 0;
403
406
  }
407
+ if (taken.error) { io.err(`digest: ${taken.error}\n`); return io.exit(1) ?? 1; }
408
+ // Every digest file sits beside the config, wherever the lookup found it.
409
+ const opened = commandConfig({ command: 'digest', cwd, argv, err: io.err });
410
+ cwd = opened.cwd;
404
411
  let config;
405
- try { config = loadConfig(join(cwd, 'honestweek.config.json')); }
412
+ try { config = loadConfig(opened.found.path); }
406
413
  catch (error) { io.err(`digest: ${error.message}\n`); return io.exit(1) ?? 1; }
407
414
  const selectedRoots = roots ?? resolvePromptRoots();
408
415
  try {
@@ -419,7 +426,7 @@ export async function runDigest({ cwd = process.cwd(), argv = [], now = new Date
419
426
  assertNoCarryPending(cwd);
420
427
  }
421
428
  if (command === 'prepare') return prepare({ cwd, config, args, now, roots: selectedRoots, io, transactionFs });
422
- if (command === 'candidates') return candidates({ cwd, config, args, now, roots: selectedRoots, io });
429
+ if (command === 'candidates') return candidates({ cwd, config, args, now, roots: selectedRoots, io, again: configAgain(taken.config) });
423
430
  if (command === 'explain') return explain({ cwd, config, args, now, roots: selectedRoots, io });
424
431
  if (command === 'reset-tombstones') return resetTombstones({ cwd, config, args, now, io, transactionFs });
425
432
  if (command === 'keep' || command === 'hide' || command === 'delete' || command === 'carry-forward') {
package/lib/discover.mjs CHANGED
@@ -15,12 +15,14 @@
15
15
  // Zero runtime dependencies: Node built-ins + system git only.
16
16
 
17
17
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
- import { join, resolve, sep } from 'node:path';
18
+ import { join } from 'node:path';
19
19
 
20
20
  import { loadConfig } from './config.mjs';
21
+ import { commandConfig } from './config-lookup.mjs';
21
22
  import { createRedactor } from './redact.mjs';
22
23
  import { resolveWeek, localDateInTimezone, WeekResolutionError } from './resolve-week.mjs';
23
24
  import { commitsInWindow, gitExec } from './git.mjs';
25
+ import { checkoutOf, reachesDisplay } from './repo-identity.mjs';
24
26
  import { adaptSessions } from './claude-adapter.mjs';
25
27
  import { discoverHandoffs } from './handoffs.mjs';
26
28
 
@@ -48,24 +50,14 @@ function parseWeekArg(argv) {
48
50
  return undefined;
49
51
  }
50
52
 
51
- /** A path made comparable: absolute, no trailing separator, case-folded on Windows. */
52
- function comparablePath(p) {
53
- let out = resolve(p);
54
- while (out.length > 1 && (out.endsWith(sep) || out.endsWith('/')) && !/^[A-Za-z]:[\\/]$/.test(out)) out = out.slice(0, -1);
55
- return process.platform === 'win32' ? out.toLowerCase() : out;
56
- }
57
-
58
- /** True when `folder` is `repo` itself or anywhere inside it. */
59
- export function isSameOrInside(folder, repo) {
60
- const f = comparablePath(folder);
61
- const r = comparablePath(repo);
62
- if (f === r) return true;
63
- return f.startsWith(r.endsWith(sep) ? r : r + sep);
64
- }
53
+ /** What discover says when it can't ask git whether the draft is committed (issue 161). */
54
+ export const CANT_CHECK_DRAFT = `discover: check that ${DRAFT_FILE}, your private draft, was never committed to git. Listing it in .gitignore doesn't remove it from git if it was committed before, and honestweek can't check for you here because of your display-only setting. Run git ls-files ${DRAFT_FILE}. If it prints the file name, run git rm --cached ${DRAFT_FILE}.\n`;
65
55
 
66
56
  /** Append DRAFT_FILE to .gitignore idempotently; warn loudly if it is tracked.
67
- * When `cwd` is a display-role repo, git is never asked (AGENTS.md invariant 4). */
68
- export function ensureDraftGitignored(cwd, io, { isDisplay = false } = {}) {
57
+ * When git running in `cwd` would reach a display-only folder, git is never asked (AGENTS.md
58
+ * invariant 4), and it says how to check by hand instead. Outside any checkout (`inRepo`
59
+ * false) there's nothing to check, so it asks git nothing and says nothing. */
60
+ export function ensureDraftGitignored(cwd, io, { isDisplay = false, inRepo = true } = {}) {
69
61
  const giPath = join(cwd, '.gitignore');
70
62
  const existing = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
71
63
  if (!existing.split(/\r?\n/).some((l) => l.trim() === DRAFT_FILE)) {
@@ -73,10 +65,14 @@ export function ensureDraftGitignored(cwd, io, { isDisplay = false } = {}) {
73
65
  writeFileSync(giPath, `${prefix}${DRAFT_FILE}\n`);
74
66
  }
75
67
  // Warn if the draft is already tracked in git (a privacy hazard).
76
- if (isDisplay) return;
68
+ if (!inRepo) return;
69
+ if (isDisplay) {
70
+ io.err(CANT_CHECK_DRAFT);
71
+ return;
72
+ }
77
73
  try {
78
74
  const tracked = gitExec(['ls-files', '--', DRAFT_FILE], { repo: cwd, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
79
- if (tracked) io.err(`discover: WARNING — ${DRAFT_FILE} is tracked in git. It must never be committed.\n`);
75
+ if (tracked) io.err(`discover: WARNING: ${DRAFT_FILE} is tracked in git. It must never be committed.\n`);
80
76
  } catch {
81
77
  /* not a git repo / git unavailable — nothing to warn about */
82
78
  }
@@ -105,9 +101,17 @@ export async function runDiscover({
105
101
  adapter = adaptSessions,
106
102
  gitWindow = commitsInWindow,
107
103
  } = {}) {
104
+ // The config can sit elsewhere (--config, HONESTWEEK_CONFIG, the user-level file); the draft
105
+ // is written beside it, never into an unrelated folder this ran in.
106
+ const opened = commandConfig({ command: 'discover', cwd, argv, err: io.err });
107
+ if (opened.error) {
108
+ io.err(`discover: ${opened.error}\n`);
109
+ return io.exit(1) ?? 1;
110
+ }
111
+ ({ cwd, argv } = opened);
108
112
  let config;
109
113
  try {
110
- config = loadConfig(join(cwd, 'honestweek.config.json'));
114
+ config = loadConfig(opened.found.path);
111
115
  } catch (err) {
112
116
  io.err(`discover: ${err.message}\n`);
113
117
  return io.exit(1) ?? 1;
@@ -175,8 +179,10 @@ export async function runDiscover({
175
179
  const redacted = redactor.deepRedact(digest);
176
180
  // A new draft is readable only by its owner on POSIX; a rewrite keeps the mode it has.
177
181
  writeFileSync(join(cwd, DRAFT_FILE), `${JSON.stringify(redacted, null, 2)}\n`, { mode: 0o600 });
178
- const cwdIsDisplay = (config.repos ?? []).some((r) => r.role === 'display' && isSameOrInside(cwd, r.resolvedPath ?? r.path));
179
- ensureDraftGitignored(cwd, io, { isDisplay: cwdIsDisplay });
182
+ // Git isn't asked where it would reach a display-only folder, the same test Settings'
183
+ // configTrackState makes, nor outside any checkout, where there's nothing to track.
184
+ const displayPaths = (config.repos ?? []).filter((r) => r.role === 'display').map((r) => r.resolvedPath ?? r.path);
185
+ ensureDraftGitignored(cwd, io, { isDisplay: reachesDisplay(displayPaths)(cwd), inRepo: checkoutOf(cwd) !== null });
180
186
 
181
187
  const publicCount = sessions.length - privateCount;
182
188
  io.out(
@@ -8,7 +8,7 @@
8
8
  // publishes it themselves.
9
9
 
10
10
  import { readFileSync, existsSync, mkdirSync } from 'node:fs';
11
- import { isAbsolute, join, dirname } from 'node:path';
11
+ import { isAbsolute, join, dirname, resolve } from 'node:path';
12
12
 
13
13
  import { OUTPUT_MODES, DEFAULT_OUTPUT_FILES } from '../config.mjs';
14
14
  import { renderSite } from '../site/emit-site.mjs';
@@ -29,7 +29,9 @@ export async function resolvePrimaryOutputPath(config, { cwd = process.cwd() } =
29
29
  const loaded = await loadSiteAdapter(config.output.adapter);
30
30
  return isAbsolute(loaded.artifact) ? loaded.artifact : join(cwd, loaded.artifact);
31
31
  }
32
- return config?.output?.file || DEFAULT_OUTPUT_FILES[config?.output?.mode ?? 'digest'];
32
+ // The same folder prepareEmission writes to, so carry recovery hashes the file build wrote.
33
+ const file = config?.output?.file || DEFAULT_OUTPUT_FILES[config?.output?.mode ?? 'digest'];
34
+ return isAbsolute(file) ? file : join(cwd, file);
33
35
  }
34
36
 
35
37
  /** The number of items rendered by a report model (loose + grouped). */
@@ -102,10 +104,19 @@ export function renderFor(mode, reportModel, config) {
102
104
  return emitter.render(reportModel, config);
103
105
  }
104
106
 
107
+ /** Whether two folders are the same one, ignoring case on Windows. */
108
+ function sameFolder(a, b) {
109
+ const x = resolve(a);
110
+ const y = resolve(b);
111
+ return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
112
+ }
113
+
105
114
  /**
106
115
  * emit(reportModel, config, { cwd }) -> { path, mode, bytes, items }
107
- * Dispatches strictly by config.output.mode and writes one local file. `cwd` (the
108
- * target root) is used only by the site mode to resolve the adapter's artifact path.
116
+ * Dispatches strictly by config.output.mode and writes one local file. `cwd` is the config's
117
+ * folder: a relative output.file resolves against it, as the site mode's artifact path does, so
118
+ * the output lands beside the config the command found (issue 194). When that's the folder the
119
+ * process runs in, the path stays as written, so what build prints doesn't change.
109
120
  */
110
121
  export function prepareEmission(reportModel, config, { cwd = process.cwd() } = {}) {
111
122
  const mode = config?.output?.mode ?? 'digest';
@@ -118,10 +129,11 @@ export function prepareEmission(reportModel, config, { cwd = process.cwd() } = {
118
129
  throw new Error('emit: site mode must be emitted via emitSite (async), not emit.');
119
130
  }
120
131
 
121
- const file = config?.output?.file || DEFAULT_OUTPUT_FILES[mode];
122
- if (!file) {
132
+ const configured = config?.output?.file || DEFAULT_OUTPUT_FILES[mode];
133
+ if (!configured) {
123
134
  throw new Error(`emit: no output file configured for mode ${JSON.stringify(mode)} and no default available.`);
124
135
  }
136
+ const file = isAbsolute(configured) || sameFolder(cwd, process.cwd()) ? configured : join(cwd, configured);
125
137
 
126
138
  let content;
127
139
  if (mode === 'changelog') {
package/lib/harvest.mjs CHANGED
@@ -17,11 +17,14 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  import { loadConfig } from './config.mjs';
20
+ import { commandConfig } from './config-lookup.mjs';
20
21
  import { currentCommand } from './invocation.mjs';
21
22
 
22
23
  const CONFIG_FILE = 'honestweek.config.json';
23
24
  const DRAFT_FILE = 'honestweek.draft.json';
24
25
  const HARVEST_FILE = 'honestweek.harvest.json';
26
+ /** The .gitignore line for harvest's sidecar, which init also writes when it sets up a folder. */
27
+ export const HARVEST_GITIGNORE = Object.freeze([HARVEST_FILE]);
25
28
 
26
29
  // Common capitalized English words / sentence-starters to ignore — high-noise,
27
30
  // low-signal. The harvester is advisory, so over-inclusion here only trims noise.
@@ -52,6 +55,13 @@ function isCandidate(w) {
52
55
  return false;
53
56
  }
54
57
 
58
+ /** True when a word is shaped like a name worth proposing and isn't a common English word:
59
+ * harvest's rules, in any alphabet, so José and Łukasz count. Settings' "Suggest words" uses it. */
60
+ export function isNameShaped(w) {
61
+ if ([...w].length < 3 || STOP.has(w.toLowerCase())) return false;
62
+ return /^\p{Lu}{3,}$/u.test(w) || /\p{Ll}\p{Lu}/u.test(w) || /^\p{Lu}\p{Ll}+$/u.test(w);
63
+ }
64
+
55
65
  /** Count proper-noun-shaped tokens in a string, minus an exclude set + STOP. */
56
66
  export function harvestNouns(text, { exclude = new Set() } = {}) {
57
67
  const counts = new Map();
@@ -119,9 +129,17 @@ function ensureHarvestGitignored(cwd) {
119
129
  * sidecar, and prints ONLY the count.
120
130
  */
121
131
  export async function runHarvest({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo() } = {}) {
132
+ // The config can sit elsewhere (--config, HONESTWEEK_CONFIG, the user-level file); harvest's
133
+ // files then sit beside it, never in an unrelated folder this ran in.
134
+ const opened = commandConfig({ command: 'harvest', cwd, argv, err: io.err });
135
+ if (opened.error) {
136
+ io.err(`harvest: ${opened.error}\n`);
137
+ return io.exit(1) ?? 1;
138
+ }
139
+ ({ cwd, argv } = opened);
122
140
  let config;
123
141
  try {
124
- config = loadConfig(join(cwd, CONFIG_FILE));
142
+ config = loadConfig(opened.found.path);
125
143
  } catch (err) {
126
144
  io.err(`harvest: ${err.message}\n`);
127
145
  return io.exit(1) ?? 1;