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