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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/README.md +252 -197
- package/SKILL.md +25 -74
- package/bin/honestweek.mjs +81 -24
- package/flows/client.md +19 -0
- package/flows/digest.md +11 -0
- package/flows/mine.md +18 -0
- package/flows/view.md +45 -0
- package/flows/weekly.md +29 -0
- package/lib/ask.mjs +1142 -0
- package/lib/build.mjs +12 -3
- package/lib/config-lookup.mjs +179 -0
- package/lib/config.mjs +20 -0
- package/lib/demo/week.mjs +3 -0
- package/lib/digest-carry.mjs +3 -2
- package/lib/digest-store.mjs +3 -2
- package/lib/digest.mjs +21 -14
- package/lib/discover.mjs +28 -22
- package/lib/emit/index.mjs +18 -6
- package/lib/harvest.mjs +19 -1
- package/lib/history.mjs +10 -2
- package/lib/init.mjs +241 -55
- package/lib/mine.mjs +18 -8
- package/lib/preview.mjs +66 -13
- package/lib/private-words.mjs +25 -5
- package/lib/problems/index.mjs +60 -7
- package/lib/prompt-lane.mjs +14 -13
- package/lib/prompt-store.mjs +2 -1
- package/lib/prompts.mjs +13 -7
- package/lib/replay/assemble.mjs +25 -8
- package/lib/replay/index.mjs +200 -12
- package/lib/replay/lookup.mjs +6 -3
- package/lib/replay/saved-sessions.mjs +315 -0
- package/lib/replay/views.mjs +12 -1
- package/lib/{view → replay}/word-index.mjs +3 -3
- package/lib/replay/words.mjs +122 -0
- package/lib/repo-identity.mjs +81 -13
- package/lib/saved/checks.mjs +381 -0
- package/lib/saved/history.mjs +235 -0
- package/lib/saved/saver.mjs +111 -0
- package/lib/saved/store.mjs +256 -0
- package/lib/status.mjs +288 -0
- package/lib/validate.mjs +12 -3
- package/lib/view/assets/common.css +2 -0
- package/lib/view/assets/common.js +20 -2
- package/lib/view/assets/problems.js +4 -3
- package/lib/view/assets/replay.js +3 -1
- package/lib/view/assets/search.js +1 -1
- package/lib/view/assets/sessions.js +1 -0
- package/lib/view/assets/settings.html +16 -0
- package/lib/view/assets/settings.js +108 -4
- package/lib/view/assets/setup.html +7 -0
- package/lib/view/assets/setup.js +9 -0
- package/lib/view/codex-judge.mjs +1 -1
- package/lib/view/data.mjs +160 -76
- package/lib/view/own-week.mjs +119 -0
- package/lib/view/page-link.mjs +84 -0
- package/lib/view/problems-route.mjs +29 -3
- package/lib/view/replay-export.mjs +1 -1
- package/lib/view/selftest/clickthrough.js +9 -5
- package/lib/view/server.mjs +3 -1
- package/lib/view/settings.mjs +102 -34
- package/lib/view/setup.mjs +27 -14
- package/lib/view/suggest-words.mjs +67 -0
- package/lib/view.mjs +82 -133
- package/lib/worktrees.mjs +31 -18
- 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' });
|
package/lib/digest-carry.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
}
|
package/lib/digest-store.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
52
|
-
|
|
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`
|
|
68
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
179
|
-
|
|
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(
|
package/lib/emit/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
108
|
-
*
|
|
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
|
|
122
|
-
if (!
|
|
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(
|
|
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;
|