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/history.mjs CHANGED
@@ -14,12 +14,12 @@
14
14
  import { join } from 'node:path';
15
15
 
16
16
  import { loadConfig } from './config.mjs';
17
+ import { commandConfig } from './config-lookup.mjs';
17
18
  import { landedCommitsInWindow } from './git.mjs';
18
19
  import { createRedactor } from './redact.mjs';
19
20
  import { ensureGitignore } from './init.mjs';
20
21
  import { atomicWriteText } from './atomic-json.mjs';
21
22
 
22
- const CONFIG_FILE = 'honestweek.config.json';
23
23
  export const HISTORY_FILE = 'honestweek.history.json';
24
24
  export const HISTORY_GITIGNORE = [HISTORY_FILE];
25
25
 
@@ -84,9 +84,17 @@ export async function runHistory({ cwd = process.cwd(), argv = [], io = defaultI
84
84
  io.err('history: needs --from YYYY-MM-DD and --to YYYY-MM-DD, with --from on or before --to.\n');
85
85
  return io.exit(1) ?? 1;
86
86
  }
87
+ // The config can sit elsewhere (--config, HONESTWEEK_CONFIG, the user-level file); history's
88
+ // files then sit beside it, never in an unrelated folder this ran in.
89
+ const opened = commandConfig({ command: 'history', cwd, argv, err: io.err });
90
+ if (opened.error) {
91
+ io.err(`history: ${opened.error}\n`);
92
+ return io.exit(1) ?? 1;
93
+ }
94
+ ({ cwd, argv } = opened);
87
95
  let config;
88
96
  try {
89
- config = loadConfig(join(cwd, CONFIG_FILE));
97
+ config = loadConfig(opened.found.path);
90
98
  } catch (err) {
91
99
  io.err(`history: ${err.message}\n`);
92
100
  return io.exit(1) ?? 1;
package/lib/init.mjs CHANGED
@@ -8,25 +8,28 @@
8
8
  //
9
9
  // Zero runtime dependencies: Node built-ins + the system `git` CLI only.
10
10
 
11
- import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
11
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync, readdirSync } from 'node:fs';
12
12
  import { createInterface } from 'node:readline';
13
13
  import { homedir } from 'node:os';
14
14
  import { basename, dirname, join, resolve } from 'node:path';
15
15
 
16
- import { hostTimezone, DEFAULT_OUTPUT_FILES, ROLES, resolveRepoPath } from './config.mjs';
16
+ import { hostTimezone, isEmailShaped, DEFAULT_OUTPUT_FILES, ROLES, resolveRepoPath } from './config.mjs';
17
17
  import { PROMPT_GITIGNORE } from './prompt-store.mjs';
18
18
  import { DIGEST_GITIGNORE } from './digest-store.mjs';
19
19
  import { CARRY_GITIGNORE } from './digest-carry.mjs';
20
20
  import { MINE_GITIGNORE } from './mine/ledger.mjs';
21
21
  import { HISTORY_GITIGNORE } from './history.mjs';
22
+ import { HARVEST_GITIGNORE } from './harvest.mjs';
23
+ import { SAVED_GITIGNORE } from './saved/store.mjs';
22
24
  import { atomicWriteText } from './atomic-json.mjs';
23
25
  import { currentCommand } from './invocation.mjs';
24
- import { privateWordCount, privateWordsNote } from './private-words.mjs';
26
+ import { findConfig, lookupUserConfig, takeConfigFlag, userConfigPath } from './config-lookup.mjs';
27
+ import { isUsedWord, neverPublicWordCount, privateWordCount, privateWordsNote } from './private-words.mjs';
25
28
  import { resolveCommonDir } from './worktrees.mjs';
26
29
  import { gitExec } from './git.mjs';
27
- import { checkDisplayOverlap, checkNestedRoles, displayTest, folderKey, repoIdentity, repoKey } from './repo-identity.mjs';
30
+ import { checkDisplayOverlap, checkNestedRoles, checkoutOf, displayTest, folderKey, nestsDisplay, reachesDisplay, repoIdentity, repoKey } from './repo-identity.mjs';
28
31
 
29
- export { checkDisplayOverlap, checkNestedRoles, displayTest, repoIdentity };
32
+ export { checkDisplayOverlap, checkNestedRoles, checkoutOf, displayTest, nestsDisplay, reachesDisplay, repoIdentity };
30
33
 
31
34
  const CONFIG_FILE = 'honestweek.config.json';
32
35
  const EXAMPLE_FILE = 'honestweek.config.example.json';
@@ -34,6 +37,8 @@ const GITIGNORE_FILE = '.gitignore';
34
37
  // The line writeInitFiles reports when it adds the config itself to .gitignore.
35
38
  const CONFIG_IGNORE_LINE = `${GITIGNORE_FILE} (+${CONFIG_FILE})`;
36
39
  const DRAFT_SIDECAR = 'honestweek.draft.json';
40
+ // Run with Codex's folder (JUDGE_DIR in lib/view/codex-judge.mjs, which imports this module).
41
+ const CODEX_JUDGMENTS_GITIGNORE = Object.freeze(['honestweek.codex-judgments/']);
37
42
 
38
43
  // The clean-room template init writes when no example exists: empty term-lists,
39
44
  // placeholder-only values, NO real paths/names/repos/emails.
@@ -116,12 +121,16 @@ function mainWorkTree(dir) {
116
121
  * "reference". Each read repository carries `lastAt`, when its newest local commit was made
117
122
  * (ms, or null), and the list is ordered: cwd's repository first, then the most recently
118
123
  * committed, then those git gave no time for, in folder order. A display-only repository is
119
- * never asked (AGENTS.md invariant 4), so it has no `lastAt` and sorts with those.
124
+ * never asked (AGENTS.md invariant 4), so it has no `lastAt` and sorts with those. Nor is a
125
+ * repository that holds a display-only folder, sits inside one, or has a worktree inside one,
126
+ * since git reading it would read that folder's history too: it keeps its default role with
127
+ * no `lastAt`, and init's nested-role refusal (checkNestedRoles) explains the conflict.
120
128
  */
121
129
  export function findRepos(cwd, authorEmail, { displayPaths = [], hasCommits = authorHasCommits, lastCommitAt = repoLastCommitAt } = {}) {
122
130
  // A repo an existing config marks display-only keeps that role and is never
123
131
  // passed to git (AGENTS.md invariant 4).
124
132
  const isDisplay = displayTest(displayPaths);
133
+ const nested = nestsDisplay(displayPaths);
125
134
  const cwdAbs = resolve(cwd);
126
135
  const parent = dirname(cwdAbs);
127
136
  const candidates = [cwdAbs];
@@ -159,8 +168,9 @@ export function findRepos(cwd, authorEmail, { displayPaths = [], hasCommits = au
159
168
  for (const f of entry.folders) found_.push({ key, repo: { path: f, label: basename(f), role: 'display' } });
160
169
  continue;
161
170
  }
162
- const role = key === cwdKey || hasCommits(entry.path, authorEmail) ? 'featured' : 'reference';
163
- const lastAt = lastCommitAt(entry.path);
171
+ const asked = !nested(entry.path) && !entry.folders.some(nested);
172
+ const role = key === cwdKey || (asked && hasCommits(entry.path, authorEmail)) ? 'featured' : 'reference';
173
+ const lastAt = asked ? lastCommitAt(entry.path) : null;
164
174
  found_.push({ key, repo: { path: entry.path, label: entry.label, role, lastAt: Number.isFinite(lastAt) ? lastAt : null } });
165
175
  }
166
176
  // cwd's repository first, then the most recently committed, then the rest in stable
@@ -176,41 +186,93 @@ export function discoverRepos(cwd, authorEmail, opts = {}) {
176
186
  return findRepos(cwd, authorEmail, opts).repos;
177
187
  }
178
188
 
189
+ /** What rewriting the config in `cwd` keeps from the one there: its display-only entries, as
190
+ * written, and its private words (the redaction lists and neverPublicTerms, leaving out blank
191
+ * entries as the redactor does, and repeats). A rewrite that dropped them would let git read a folder the
192
+ * person marked display-only, or stop hiding words they listed. An absent config keeps
193
+ * nothing; one that's there but can't be read keeps nothing, and `unreadable` says why ("not
194
+ * valid JSON", "not a config object", a plain reason for a file it can't open, or the error
195
+ * code), else it's false. Read-only. */
196
+ export function keptFromOldConfig(cwd, file = join(cwd, CONFIG_FILE)) {
197
+ const none = (why) => ({ display: [], names: [], terms: [], codenames: [], neverPublicTerms: [], unreadable: why });
198
+ try {
199
+ const config = JSON.parse(readFileSync(file, 'utf8'));
200
+ // JSON that isn't a config can't say which folders are display-only either.
201
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return none('not a config object');
202
+ if (config.repos !== undefined && !Array.isArray(config.repos)) return none('its repos is not a list');
203
+ const words = (list) => (Array.isArray(list) ? [...new Set(list.filter(isUsedWord))] : []);
204
+ const display = (Array.isArray(config?.repos) ? config.repos : [])
205
+ .filter((r) => r?.role === 'display' && typeof r.path === 'string')
206
+ .map((r) => ({ path: r.path, label: typeof r.label === 'string' && r.label ? r.label : basename(r.path), role: 'display' }));
207
+ const r = config?.redaction;
208
+ return { display, names: words(r?.names), terms: words(r?.terms), codenames: words(r?.codenames), neverPublicTerms: words(config?.privacy?.publicRenditions?.neverPublicTerms), unreadable: false };
209
+ } catch (err) {
210
+ // A link to a missing file is still there: lstat sees it where existsSync follows it.
211
+ let there = true;
212
+ try {
213
+ lstatSync(file);
214
+ } catch {
215
+ there = false;
216
+ }
217
+ return none(!there ? false : err instanceof SyntaxError ? 'not valid JSON' : (UNREADABLE_WHY[err?.code] ?? String(err?.code ?? 'unreadable')));
218
+ }
219
+ }
220
+
221
+ /** Plain words for why a config that's there can't be read. */
222
+ const UNREADABLE_WHY = { EACCES: 'permission denied', EPERM: 'permission denied', EISDIR: "it's a folder", ENOENT: 'it links to a file that is missing', ELOOP: 'it links in a loop' };
223
+
179
224
  /** Paths an existing config in `cwd` marks display-only, resolved against `cwd`.
180
225
  * An absent or unreadable config marks none. Read-only. */
181
226
  export function existingDisplayRepos(cwd) {
182
- try {
183
- const config = JSON.parse(readFileSync(join(cwd, CONFIG_FILE), 'utf8'));
184
- return (Array.isArray(config?.repos) ? config.repos : []).filter((r) => r?.role === 'display' && typeof r.path === 'string').map((r) => resolveRepoPath(r.path, cwd));
185
- } catch {
186
- return [];
187
- }
227
+ return keptFromOldConfig(cwd).display.map((d) => resolveRepoPath(d.path, cwd));
228
+ }
229
+
230
+ /** The config every command run in `cwd` reads when the lookup found it outside `cwd`
231
+ * (HONESTWEEK_CONFIG or the user-level file): its path, the folders it marks display-only
232
+ * (resolved against its own folder), and why it can't be read, if it can't. null when the
233
+ * lookup is off, or this folder has its own config, or there's none elsewhere. Read-only. */
234
+ export function lookedUpConfig(cwd) {
235
+ const found = findConfig({ cwd });
236
+ if (found.source !== 'env' && found.source !== 'user') return null;
237
+ const dir = dirname(found.path);
238
+ const kept = keptFromOldConfig(dir, found.path);
239
+ return { path: found.path, unreadable: kept.unreadable, display: kept.display.map((d) => resolveRepoPath(d.path, dir)) };
188
240
  }
189
241
 
190
242
  /** Assemble the config object from the inferred pieces and the private words given. The
191
243
  * setup page may give several `authorEmails`, a `goalsFile` and a `history`; init gives none. */
192
- export function buildConfig({ authorEmail, authorEmails, repos, timezone, names = [], terms = [], goalsFile, history }) {
244
+ export function buildConfig({ authorEmail, authorEmails, repos, timezone, names = [], terms = [], codenames = [], neverPublicTerms = [], goalsFile, history }) {
193
245
  return {
194
246
  identity: { authorEmails: authorEmails ? [...authorEmails] : authorEmail ? [authorEmail] : [] },
195
247
  week: { startsOn: 'monday', timezone: timezone || 'UTC' },
196
248
  repos: repos.map((r) => ({ path: r.path, label: r.label, role: r.role })),
197
- redaction: { codenames: [], names: [...names], terms: [...terms] },
249
+ redaction: { codenames: [...codenames], names: [...names], terms: [...terms] },
198
250
  curation: { maxItems: 12, automaticMinScore: 2, retentionWeeks: 12, automaticCarryWeeks: 2, categoryCaps: { prompts: 2, ideas: 2, techniques: 3, decisions: 2, reversals: 1, nextSteps: 2 } },
199
- privacy: { publicRenditions: { enabled: true, maxAutomaticChangedPercent: 20, generalizationMappings: {}, neverPublicTerms: [] } },
251
+ privacy: { publicRenditions: { enabled: true, maxAutomaticChangedPercent: 20, generalizationMappings: {}, neverPublicTerms: [...neverPublicTerms] } },
200
252
  output: { mode: 'digest', file: DEFAULT_OUTPUT_FILES.digest },
201
253
  ...(goalsFile ? { goalsFile } : {}),
202
254
  ...(history ? { history } : {}),
203
255
  };
204
256
  }
205
257
 
258
+ const hasLine = (text, entry) => text.split(/\r?\n/).some((l) => l.trim() === entry);
259
+
260
+ /** Whether `.gitignore` in `cwd` already has `entry` as a line, so ensureGitignore would add nothing. */
261
+ export function gitignoreHas(cwd, entry) {
262
+ try {
263
+ return hasLine(readFileSync(join(cwd, GITIGNORE_FILE), 'utf8'), entry);
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
206
269
  /** Append `entry` to `.gitignore` idempotently (create if absent). Returns true
207
270
  * if a line was added. A new .gitignore gets the system's normal mode, not the owner-only
208
271
  * one private stores get: it's a file the user commits, and it holds no private words. */
209
272
  export function ensureGitignore(cwd, entry, fs) {
210
273
  const file = join(cwd, GITIGNORE_FILE);
211
274
  const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
212
- const present = existing.split(/\r?\n/).some((l) => l.trim() === entry);
213
- if (present) return false;
275
+ if (hasLine(existing, entry)) return false;
214
276
  const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : `${existing}\n`;
215
277
  atomicWriteText(file, `${prefix}${entry}\n`, fs, { newMode: null });
216
278
  return true;
@@ -219,12 +281,12 @@ export function ensureGitignore(cwd, entry, fs) {
219
281
  /**
220
282
  * writeInitFiles(cwd, config, { force }) -> { wrote, skipped }
221
283
  * Writes honestweek.config.json (overwriting only when force), the generic
222
- * example if absent, and the .gitignore entries: the draft and the other private files, and
223
- * the config itself when it lists private words. The ONLY disk writes. With `onlyNew` (the
284
+ * example if absent, and the .gitignore entries: the config itself, which always holds my email
285
+ * and folder paths, the draft and every other private file honestweek makes. The ONLY disk writes. With `onlyNew` (the
224
286
  * setup page), a config already there, even one that appears while this runs, means nothing
225
- * at all is written.
287
+ * at all is written. `example: false` leaves the example out (the user-level folder).
226
288
  */
227
- export function writeInitFiles(cwd, config, { force = false, onlyNew = false } = {}) {
289
+ export function writeInitFiles(cwd, config, { force = false, onlyNew = false, example = true } = {}) {
228
290
  const wrote = [];
229
291
  const skipped = [];
230
292
  const configPath = join(cwd, CONFIG_FILE);
@@ -247,18 +309,21 @@ export function writeInitFiles(cwd, config, { force = false, onlyNew = false } =
247
309
  }
248
310
 
249
311
  const examplePath = join(cwd, EXAMPLE_FILE);
250
- if (existsSync(examplePath)) {
312
+ if (!example) {
313
+ // The user-level folder holds no project, so it gets no example to commit.
314
+ } else if (existsSync(examplePath)) {
251
315
  skipped.push(`${EXAMPLE_FILE} (already exists)`);
252
316
  } else {
253
317
  writeFileSync(examplePath, `${JSON.stringify(EXAMPLE_CONFIG, null, 2)}\n`);
254
318
  wrote.push(EXAMPLE_FILE);
255
319
  }
256
320
 
257
- // A config that lists private words holds those words as written, so it stays out of git.
258
- if (privateWordCount(config) > 0 && ensureGitignore(cwd, CONFIG_FILE)) wrote.push(CONFIG_IGNORE_LINE);
321
+ // Every config holds an email and folder paths, and private words added later are written as
322
+ // typed, so it stays out of git from the start: ignoring it only once it's committed is too late.
323
+ if (ensureGitignore(cwd, CONFIG_FILE)) wrote.push(CONFIG_IGNORE_LINE);
259
324
  if (ensureGitignore(cwd, DRAFT_SIDECAR)) wrote.push(`${GITIGNORE_FILE} (+${DRAFT_SIDECAR})`);
260
325
  else skipped.push(`${GITIGNORE_FILE} (${DRAFT_SIDECAR} already ignored)`);
261
- for (const entry of [...PROMPT_GITIGNORE, ...DIGEST_GITIGNORE, ...CARRY_GITIGNORE, ...MINE_GITIGNORE, ...HISTORY_GITIGNORE]) {
326
+ for (const entry of [...PROMPT_GITIGNORE, ...DIGEST_GITIGNORE, ...CARRY_GITIGNORE, ...MINE_GITIGNORE, ...HISTORY_GITIGNORE, ...HARVEST_GITIGNORE, ...CODEX_JUDGMENTS_GITIGNORE, ...SAVED_GITIGNORE]) {
262
327
  if (ensureGitignore(cwd, entry)) wrote.push(`${GITIGNORE_FILE} (+${entry})`);
263
328
  else skipped.push(`${GITIGNORE_FILE} (${entry} already ignored)`);
264
329
  }
@@ -267,20 +332,54 @@ export function writeInitFiles(cwd, config, { force = false, onlyNew = false } =
267
332
  }
268
333
 
269
334
  function parseFlags(argv) {
270
- const flags = { yes: false, force: false };
271
- for (const a of argv ?? []) {
335
+ const taken = takeConfigFlag(argv ?? []);
336
+ const flags = { yes: false, force: false, user: false, config: taken.config, error: taken.error };
337
+ for (const a of taken.argv) {
272
338
  if (a === '--yes' || a === '-y') flags.yes = true;
273
339
  else if (a === '--force') flags.force = true;
340
+ else if (a === '--user') flags.user = true;
274
341
  }
342
+ if (!flags.error && flags.user && flags.config !== undefined) flags.error = '--user and --config both say where to write; give one.';
275
343
  return flags;
276
344
  }
277
345
 
346
+ /** Where init writes: this folder, the user-level folder (--user), or the file --config names,
347
+ * which must be called honestweek.config.json so every command and Settings can find it. */
348
+ function initTarget(cwd, flags) {
349
+ if (flags.user) return { dir: dirname(lookupUserConfig() ?? userConfigPath()), make: true };
350
+ if (flags.config === undefined) return { dir: cwd, make: false };
351
+ const file = resolve(cwd, flags.config);
352
+ if (basename(file) !== CONFIG_FILE) return { error: `--config for init must name a file called ${CONFIG_FILE}.` };
353
+ let isFolder = false;
354
+ try {
355
+ isFolder = statSync(dirname(file)).isDirectory();
356
+ } catch {
357
+ isFolder = false;
358
+ }
359
+ if (!isFolder) return { error: `there's no folder at ${dirname(file)} to write ${CONFIG_FILE} in.` };
360
+ return { dir: resolve(dirname(file)) === resolve(cwd) ? cwd : dirname(file), make: false };
361
+ }
362
+
278
363
  const isYes = (s, dflt) => {
279
364
  const t = String(s ?? '').trim().toLowerCase();
280
365
  if (t === '') return dflt;
281
366
  return t === 'y' || t === 'yes';
282
367
  };
283
368
 
369
+ /** The question init asks when git doesn't know the person's email, as Setup does. */
370
+ export const EMAIL_QUESTION = "git doesn't know your email. Type the one your commits use, or press Enter to skip: ";
371
+
372
+ /** Ask for the email commits use; Enter skips. Three tries at most, so a script can't loop. */
373
+ async function askEmail(io) {
374
+ for (let tries = 0; tries < 3; tries++) {
375
+ const answer = String((await io.prompt(EMAIL_QUESTION)) ?? '').trim();
376
+ if (!answer) return null;
377
+ if (isEmailShaped(answer)) return answer;
378
+ io.err(` (that isn't an email address; type one like you@example.com, or press Enter to skip)\n`);
379
+ }
380
+ return null;
381
+ }
382
+
284
383
  /** Marker for "stdin ended before a confirmation could be answered". */
285
384
  const STDIN_EOF = 'HONESTWEEK_STDIN_EOF';
286
385
 
@@ -414,9 +513,12 @@ export const TERMS_QUESTION = 'Client, company or project words to hide, separat
414
513
 
415
514
  /** Ask for the names and client words to keep private. Either can be skipped. Each answer is
416
515
  * read back as it will be stored, on the person's own terminal, so a misread shows before
417
- * anything is written. */
418
- async function askPrivateWords(io) {
516
+ * anything is written. Words the old config already lists stay, and only their count is
517
+ * printed; the answers add to them. */
518
+ async function askPrivateWords(io, kept) {
419
519
  io.out(`\n${PRIVATE_WORDS_INTRO}\n`);
520
+ const keptCount = keptWordCount(kept);
521
+ if (keptCount) io.out(` Your old config already lists ${count(keptCount, 'private word')}. They stay; add any others below.\n`);
420
522
  const ask = async (question) => {
421
523
  const answer = await io.prompt(question);
422
524
  const words = parseWordList(answer);
@@ -424,23 +526,30 @@ async function askPrivateWords(io) {
424
526
  else if (answer.trim()) io.out(' Nothing to hide there: each entry needs at least two characters.\n');
425
527
  return words;
426
528
  };
427
- const names = await ask(NAMES_QUESTION);
428
- const terms = await ask(TERMS_QUESTION);
429
- return { names, terms };
529
+ const both = (old, added) => [...new Set([...old, ...added])];
530
+ const names = both(kept.names, await ask(NAMES_QUESTION));
531
+ const terms = both(kept.terms, await ask(TERMS_QUESTION));
532
+ return { names, terms, codenames: [...kept.codenames], neverPublicTerms: [...kept.neverPublicTerms] };
430
533
  }
431
534
 
535
+ const keptWordCount = (kept) => kept.names.length + kept.terms.length + kept.codenames.length + kept.neverPublicTerms.length;
536
+
537
+ /** The private words a config init writes lists, counted as keptWordCount counts them: the
538
+ * redaction lists and neverPublicTerms, so what init prints about them agrees. */
539
+ const configWordCount = (config) => privateWordCount(config) + neverPublicWordCount(config);
540
+
432
541
  const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
433
542
 
434
543
  /** What the config will say, in a few lines, instead of the whole file. */
435
544
  function summarize(config) {
436
545
  const roles = ROLES.map((role) => [role, config.repos.filter((r) => r.role === role).length]).filter(([, n]) => n > 0);
437
- const { names, terms, codenames } = config.redaction;
438
- const words = names.length + terms.length + codenames.length;
546
+ const { names } = config.redaction;
547
+ const words = configWordCount(config);
439
548
  return [
440
549
  ` You: ${config.identity.authorEmails.join(', ') || 'no email found (fill in identity.authorEmails before build)'}. A commit counts as yours only when ${config.identity.authorEmails.length > 1 ? 'one of these addresses' : 'this address'} wrote it.`,
441
550
  ` Weeks: start on Monday, in ${config.week.timezone}.`,
442
551
  ` Repositories: ${config.repos.length} (${roles.map(([role, n]) => `${n} ${role}`).join(', ')}).`,
443
- ` Private words: ${words ? `${count(names.length, 'name')}, ${count(terms.length + codenames.length, 'client or project word')}` : 'none'}.`,
552
+ ` Private words: ${words ? `${count(names.length, 'name')}, ${count(words - names.length, 'client or project word')}` : 'none'}.`,
444
553
  ` Weekly summary: a private digest written to ${config.output.file}.`,
445
554
  ].join('\n');
446
555
  }
@@ -455,10 +564,12 @@ function reportWrite(io, result, config) {
455
564
  const had = result.skipped.filter(isIgnoreLine).length;
456
565
  if (had) io.out(` skipped ${GITIGNORE_FILE} (${count(had, 'entry was', 'entries were')} already there)\n`);
457
566
  if (result.wrote.includes(CONFIG_IGNORE_LINE)) {
458
- io.out(` ${CONFIG_FILE} lists your private words, so it's now in ${GITIGNORE_FILE}. That keeps git from picking up a new file, not one it already tracks: if you've committed it before, git rm --cached ${CONFIG_FILE} stops that.\n`);
567
+ io.out(` ${CONFIG_FILE} holds your email, folder paths and any private words, so it's now in ${GITIGNORE_FILE}. If you committed it before, listing it there doesn't remove it from git: run git rm --cached ${CONFIG_FILE}.\n`);
459
568
  }
460
569
  const cmd = currentCommand();
461
- if (config && privateWordCount(config) === 0) io.out(`\n${privateWordsNote(cmd, { restart: false })}\n`);
570
+ // Only the redaction lists hide a word in the person's own pages, so the note's gate is
571
+ // privateWordCount; words kept out of public versions only are named in it, not counted as hiding.
572
+ if (config && privateWordCount(config) === 0) io.out(`\n${privateWordsNote(cmd, { restart: false, publicOnly: neverPublicWordCount(config) })}\n`);
462
573
  io.out(`\nNext, find, check and replay your sessions in your browser:\n ${cmd} view\nFor a weekly summary of last week, start with:\n ${cmd} discover\n`);
463
574
  }
464
575
 
@@ -473,18 +584,51 @@ export function foundLine(repos, folded) {
473
584
 
474
585
  /** The display-only folders an existing config lists, and the email inferred from git. No
475
586
  * folder of a display-only repository (its main checkout, a worktree, or a plain subfolder
476
- * of either) is passed to git, so the email there comes from the global git config. */
477
- export function inferIdentity(cwd, { inferEmail = inferAuthorEmail } = {}) {
478
- const displayPaths = existingDisplayRepos(cwd);
587
+ * of either) is passed to git, nor one that holds a display-only folder, sits inside one, or
588
+ * sits in a checkout that holds one (nestsDisplay), so the email there comes from the global
589
+ * git config. */
590
+ export function inferIdentity(cwd, { inferEmail = inferAuthorEmail, configDir = cwd } = {}) {
591
+ // Writing a config elsewhere (init --user, --config) still never runs git on a folder any
592
+ // config marks display-only: the one here, the one being rewritten, or the one every other
593
+ // command run here reads (HONESTWEEK_CONFIG, the user-level file).
594
+ const displayPaths = [...new Set([...existingDisplayRepos(cwd), ...(resolve(configDir) === resolve(cwd) ? [] : existingDisplayRepos(configDir)), ...(lookedUpConfig(cwd)?.display ?? [])])];
479
595
  const isDisplay = displayTest(displayPaths);
480
- return { displayPaths, authorEmail: inferEmail(cwd, { isDisplay: isDisplay(cwd, { walk: true }) }) };
596
+ return { displayPaths, authorEmail: inferEmail(cwd, { isDisplay: isDisplay(cwd, { walk: true }) || nestsDisplay(displayPaths)(cwd) }) };
481
597
  }
482
598
 
483
599
  /** Core init flow with injectable cwd/argv/io (for testability). */
484
600
  export async function runInit({ cwd = process.cwd(), argv = [], io = defaultIo(), inferEmail = inferAuthorEmail } = {}) {
485
601
  const flags = parseFlags(argv);
486
- io.out(`honestweek init: set up ${CONFIG_FILE} in ${resolve(cwd)}.${flags.yes ? '' : ' Nothing is written until you say yes.'}\n`);
487
- const { displayPaths, authorEmail } = inferIdentity(cwd, { inferEmail });
602
+ const target = flags.error ? { error: flags.error } : initTarget(cwd, flags);
603
+ if (target.error) {
604
+ io.err(`honestweek init: ${target.error} Nothing was written.\n`);
605
+ return 1;
606
+ }
607
+ // The config goes in `dir`: this folder, unless --user or --config said otherwise. The
608
+ // repositories are still looked for here and in the folders next to it.
609
+ const dir = target.dir;
610
+ io.out(`honestweek init: set up ${CONFIG_FILE} in ${resolve(dir)}.${flags.yes ? '' : ' Nothing is written until you say yes.'}\n`);
611
+ // A config that's there but can't be read can't say which folders are display-only, so init
612
+ // stops before it runs git anywhere (AGENTS.md invariant 4; issue 171).
613
+ const kept = keptFromOldConfig(dir);
614
+ if (kept.unreadable) {
615
+ io.err(`${CONFIG_FILE} is here but can't be read (${kept.unreadable}), so init can't tell which folders you marked display-only, and it runs git nowhere until it can. Fix the file, or move it away to start fresh, then run init again. Nothing was written.\n`);
616
+ return 1;
617
+ }
618
+ // Writing elsewhere, the config in this folder still marks folders display-only, and so does
619
+ // the one the other commands read from this folder (HONESTWEEK_CONFIG, the user-level file).
620
+ const here = resolve(dir) === resolve(cwd) ? null : keptFromOldConfig(cwd);
621
+ const around = lookedUpConfig(cwd);
622
+ const blocked = here?.unreadable ? { path: join(resolve(cwd), CONFIG_FILE), why: here.unreadable } : around?.unreadable ? { path: around.path, why: around.unreadable } : null;
623
+ if (blocked) {
624
+ io.err(`${blocked.path} can't be read (${blocked.why}), so init can't tell which folders you marked display-only, and it runs git nowhere until it can. Fix the file, or move it away, then run init again. Nothing was written.\n`);
625
+ return 1;
626
+ }
627
+ const identity = inferIdentity(cwd, { inferEmail, configDir: dir });
628
+ const { displayPaths } = identity;
629
+ // Asked in a terminal, a missing email is a question rather than a warning: build's authorship
630
+ // check needs it (issue 163). With --yes there's nobody to ask.
631
+ const authorEmail = identity.authorEmail ?? (flags.yes ? null : await askEmail(io));
488
632
  if (!authorEmail) {
489
633
  io.err(
490
634
  'Warning: could not infer your git user.email. identity.authorEmails will be empty. Fill it in before running build, or the authorship check cannot pass.\n'
@@ -494,10 +638,26 @@ export async function runInit({ cwd = process.cwd(), argv = [], io = defaultIo()
494
638
  const { repos, folded } = findRepos(cwd, authorEmail, { displayPaths });
495
639
  if (repos.length) io.out(`${foundLine(repos, folded)}\n`);
496
640
  const timezone = hostTimezone();
497
- const configExists = existsSync(join(cwd, CONFIG_FILE));
641
+ const configExists = existsSync(join(dir, CONFIG_FILE));
642
+ // A rewrite keeps the old config's private words, and its display-only folders the list
643
+ // doesn't show: the person never saw those, so never chose to drop them. The list names each
644
+ // folder of a display-only repository separately (findRepos), so they're matched by folder,
645
+ // not by repository. Only counts are printed, since a label can be a client's name, and a
646
+ // kept folder joins the nested check by its path alone, so a refusal names it by its folder.
647
+ const shown = new Set(repos.map((r) => folderKey(r.path)));
648
+ const unseen = kept.display.filter((d) => !shown.has(folderKey(resolveRepoPath(d.path, dir))));
649
+ const keptWords = keptWordCount(kept);
650
+ const sayKeptFolders = () => {
651
+ if (unseen.length) io.out(`Keeping ${count(unseen.length, 'display-only folder')} from your old config that this search didn't list.\n`);
652
+ };
653
+ const forCheck = (list) => [...list.map((r) => ({ ...r, path: resolveRepoPath(r.path, cwd) })), ...unseen.map((d) => ({ path: resolveRepoPath(d.path, dir), role: 'display' }))];
498
654
  // A config with no repositories is one view and discover refuse, so none is written.
499
655
  // Nor is one where git would read a display-only repository (checkDisplayOverlap): the same
500
- // repository as display and read, or one inside the other.
656
+ // repository as display and read, or one inside the other. The display-only folders the old
657
+ // config lists join the nested check, so a repository holding one, or whose folder is inside
658
+ // one, or has a worktree inside one, is refused rather than written as one git reads.
659
+ const listedDisplay = displayPaths.map((p) => ({ path: p, role: 'display' }));
660
+ const overlap = (list) => checkDisplayOverlap(list) ?? checkNestedRoles([...list, ...listedDisplay]);
501
661
  const refuseNested = (line) => {
502
662
  io.err(`${line} Nothing was written.
503
663
  `);
@@ -515,10 +675,13 @@ export async function runInit({ cwd = process.cwd(), argv = [], io = defaultIo()
515
675
  return 0;
516
676
  }
517
677
  if (!repos.length) return noRepos();
518
- const nested = checkDisplayOverlap(repos);
678
+ const list = [...repos, ...unseen];
679
+ const nested = overlap(forCheck(repos));
519
680
  if (nested) return refuseNested(nested);
520
- const config = buildConfig({ authorEmail, repos, timezone });
521
- reportWrite(io, writeInitFiles(cwd, config, { force: true }), config);
681
+ sayKeptFolders();
682
+ if (keptWords) io.out(`Keeping the ${count(keptWords, 'private word')} your old config lists.\n`);
683
+ const config = buildConfig({ authorEmail, repos: list, timezone, names: kept.names, terms: kept.terms, codenames: kept.codenames, neverPublicTerms: kept.neverPublicTerms });
684
+ reportWrite(io, writeTo(dir, config, target), config);
522
685
  return 0;
523
686
  }
524
687
 
@@ -529,18 +692,34 @@ export async function runInit({ cwd = process.cwd(), argv = [], io = defaultIo()
529
692
 
530
693
  // First confirmation: the repositories (after any edits).
531
694
  const finalRepos = await editAllowlist(io, repos);
532
- const nested = checkDisplayOverlap(finalRepos.map((r) => ({ ...r, path: resolveRepoPath(r.path, cwd) })));
533
- if (nested) return refuseNested(nested);
695
+ const finalList = [...finalRepos, ...unseen];
696
+ // A repository that holds a display-only folder, or sits inside one, can be marked
697
+ // display-only too, so init offers that rather than stopping (issue 163). The folder init runs
698
+ // in defaults to no, since marking it means git reads none of it. The reason shown is the
699
+ // culprit's own, so the question never names a different repository than the line above it.
700
+ for (let nested = overlap(forCheck(finalRepos)); nested; nested = overlap(forCheck(finalRepos))) {
701
+ const display = [...forCheck(finalRepos).filter((x) => x.role === 'display'), ...listedDisplay];
702
+ const reasonFor = (r) => (r.role === 'display' ? null : checkNestedRoles([{ ...r, path: resolveRepoPath(r.path, cwd) }, ...display]));
703
+ const culprit = finalRepos.find((r) => reasonFor(r) !== null);
704
+ if (!culprit) return refuseNested(nested);
705
+ const reason = reasonFor(culprit);
706
+ const here = repoIdentity(resolveRepoPath(culprit.path, cwd)) === repoIdentity(resolve(cwd));
707
+ const answer = await io.prompt(`\n${reason}\nMark ${culprit.label} display-only too? [${here ? 'y/N' : 'Y/n'}] `);
708
+ if (!isYes(answer, !here)) return refuseNested(reason);
709
+ culprit.role = 'display';
710
+ io.out(` ${culprit.label} is display-only now, so git won't read it.\n`);
711
+ }
712
+ sayKeptFolders();
534
713
  const ok1 = await io.prompt(`\nUse ${finalRepos.length === 1 ? 'this repository' : `these ${finalRepos.length} repositories`}? [Y/n] `);
535
714
  if (!isYes(ok1, true)) {
536
715
  io.out('Aborted; nothing was written.\n');
537
716
  return 1;
538
717
  }
539
718
 
540
- const { names, terms } = await askPrivateWords(io);
719
+ const { names, terms, codenames, neverPublicTerms } = await askPrivateWords(io, kept);
541
720
 
542
721
  // Second confirmation: what the config will say, before writing.
543
- const config = buildConfig({ authorEmail, repos: finalRepos, timezone, names, terms });
722
+ const config = buildConfig({ authorEmail, repos: finalList, timezone, names, terms, codenames, neverPublicTerms });
544
723
  io.out(`\n${CONFIG_FILE} will say:\n${summarize(config)}\nIt's a plain JSON file, so you can open it and change any of this later.\n`);
545
724
  const writeDefault = !configExists; // default-yes for fresh setup, default-no to overwrite
546
725
  const ok2 = await io.prompt(`\nWrite ${CONFIG_FILE} now? [${writeDefault ? 'Y/n' : 'y/N'}] `);
@@ -549,10 +728,17 @@ export async function runInit({ cwd = process.cwd(), argv = [], io = defaultIo()
549
728
  return 1;
550
729
  }
551
730
 
552
- reportWrite(io, writeInitFiles(cwd, config, { force: true }), config);
731
+ reportWrite(io, writeTo(dir, config, target), config);
553
732
  return 0;
554
733
  }
555
734
 
735
+ /** writeInitFiles in init's target folder, made first when it's the user-level one, which gets
736
+ * no example file: it holds no project to commit one in. */
737
+ function writeTo(dir, config, target) {
738
+ if (target.make) mkdirSync(dir, { recursive: true, mode: 0o700 });
739
+ return writeInitFiles(dir, config, { force: true, example: !target.make });
740
+ }
741
+
556
742
  export default async function run(argv) {
557
743
  // A stdin that ends before the questions are answered lands here. Left
558
744
  // unhandled that was a silent exit 0 with nothing written, which reads as
package/lib/mine.mjs CHANGED
@@ -20,21 +20,23 @@
20
20
  //
21
21
  // Zero runtime dependencies: Node built-ins only.
22
22
 
23
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
23
+ import { mkdirSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, resolve } from 'node:path';
25
25
 
26
26
  import { loadConfig } from './config.mjs';
27
+ import { findConfig, noteConfig } from './config-lookup.mjs';
27
28
  import { createRedactor } from './redact.mjs';
28
29
  import { CORPUS_KINDS, enumerateSessions } from './mine/corpus.mjs';
29
30
  import { detectSession } from './mine/detect.mjs';
30
31
  import { rankFindings, publishable, PUBLISHABLE_THRESHOLD } from './mine/rank.mjs';
31
32
  import { renderDraft } from './mine/draft.mjs';
32
33
  import { ALL_STATUSES, backlog, errorSignal, loadLedger, mergeFindings, nextToDraft, recordRun, saveLedger, setStatus } from './mine/ledger.mjs';
34
+ import { currentCommand } from './invocation.mjs';
33
35
 
34
36
  const USAGE = `honestweek mine: find solved third-party problems in your session logs.
35
37
 
36
38
  Usage:
37
- honestweek mine [options]
39
+ ${currentCommand()} mine [options]
38
40
 
39
41
  What it does:
40
42
  Reads your agent session logs, finds sessions where software you did NOT write
@@ -42,8 +44,11 @@ What it does:
42
44
  ledger. With --draft it also writes the top undecided one up as a post.
43
45
 
44
46
  Options:
45
- --config <path> Config file (default: honestweek.config.json).
46
- --ledger <path> Findings ledger (default: honestweek.findings.json).
47
+ --config <path> Config file (default: the one honestweek finds: this folder's
48
+ honestweek.config.json, then HONESTWEEK_CONFIG, then
49
+ ~/.honestweek/honestweek.config.json).
50
+ --ledger <path> Findings ledger (default: honestweek.findings.json, beside
51
+ the config).
47
52
  --since <date> Only sessions starting on/after this ISO date.
48
53
  --corpus <list> Comma-separated: claude-code,codex,cowork (default: all).
49
54
  --draft Write a draft for the top undecided finding and mark it drafted.
@@ -154,9 +159,14 @@ export default async function run(argv) {
154
159
  // loaded, must fail LOUD: continuing would silently rank the user's own-repo issues
155
160
  // as third-party evidence (mine.ownRepos gone) and run with the redaction denylist
156
161
  // off, in a command whose output the docs say to commit.
157
- const configPath = resolve(args.config ?? 'honestweek.config.json');
162
+ // The config can sit elsewhere (HONESTWEEK_CONFIG, the user-level file); the ledger and drafts
163
+ // then sit beside it, never in an unrelated folder this ran in.
164
+ const found = findConfig({ cwd: process.cwd(), flag: args.config });
165
+ noteConfig((s) => process.stderr.write(s), 'mine', found, { writes: true });
166
+ const configPath = resolve(found.path);
167
+ const base = found.dir;
158
168
  let config = {};
159
- if (args.config !== undefined || existsSync(configPath)) {
169
+ if (found.source !== 'none') {
160
170
  try {
161
171
  config = loadConfig(configPath);
162
172
  } catch (err) {
@@ -167,7 +177,7 @@ export default async function run(argv) {
167
177
  process.stderr.write(`honestweek mine: no config at ${configPath}; continuing without repo context.\n`);
168
178
  }
169
179
 
170
- const ledgerPath = resolve(args.ledger ?? config?.mine?.ledger ?? 'honestweek.findings.json');
180
+ const ledgerPath = args.ledger !== undefined ? resolve(args.ledger) : resolve(base, config?.mine?.ledger ?? 'honestweek.findings.json');
171
181
  const ledger = loadLedger(ledgerPath);
172
182
  const now = new Date();
173
183
 
@@ -322,7 +332,7 @@ export default async function run(argv) {
322
332
  const target = nextToDraft(ledger);
323
333
  if (target) {
324
334
  const { path, body, title } = renderDraft(target, { config, now, redactor });
325
- const outPath = resolve(path);
335
+ const outPath = resolve(base, path);
326
336
  mkdirSync(dirname(outPath), { recursive: true });
327
337
  writeFileSync(outPath, body, 'utf8');
328
338
  setStatus(ledger, target.key, 'drafted', { now, draftPath: path });