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/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(
|
|
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 {
|
|
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
|
|
163
|
-
const
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
223
|
-
* 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 (
|
|
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
|
-
//
|
|
258
|
-
|
|
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
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
428
|
-
const
|
|
429
|
-
|
|
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
|
|
438
|
-
const words =
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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,
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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(
|
|
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
|
|
678
|
+
const list = [...repos, ...unseen];
|
|
679
|
+
const nested = overlap(forCheck(repos));
|
|
519
680
|
if (nested) return refuseNested(nested);
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
533
|
-
|
|
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:
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 });
|