honestweek 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -2
  3. package/README.md +252 -197
  4. package/SKILL.md +25 -74
  5. package/bin/honestweek.mjs +81 -24
  6. package/flows/client.md +19 -0
  7. package/flows/digest.md +11 -0
  8. package/flows/mine.md +18 -0
  9. package/flows/view.md +45 -0
  10. package/flows/weekly.md +29 -0
  11. package/lib/ask.mjs +1142 -0
  12. package/lib/build.mjs +12 -3
  13. package/lib/config-lookup.mjs +179 -0
  14. package/lib/config.mjs +20 -0
  15. package/lib/demo/week.mjs +3 -0
  16. package/lib/digest-carry.mjs +3 -2
  17. package/lib/digest-store.mjs +3 -2
  18. package/lib/digest.mjs +21 -14
  19. package/lib/discover.mjs +28 -22
  20. package/lib/emit/index.mjs +18 -6
  21. package/lib/harvest.mjs +19 -1
  22. package/lib/history.mjs +10 -2
  23. package/lib/init.mjs +241 -55
  24. package/lib/mine.mjs +18 -8
  25. package/lib/preview.mjs +66 -13
  26. package/lib/private-words.mjs +25 -5
  27. package/lib/problems/index.mjs +60 -7
  28. package/lib/prompt-lane.mjs +14 -13
  29. package/lib/prompt-store.mjs +2 -1
  30. package/lib/prompts.mjs +13 -7
  31. package/lib/replay/assemble.mjs +25 -8
  32. package/lib/replay/index.mjs +200 -12
  33. package/lib/replay/lookup.mjs +6 -3
  34. package/lib/replay/saved-sessions.mjs +315 -0
  35. package/lib/replay/views.mjs +12 -1
  36. package/lib/{view → replay}/word-index.mjs +3 -3
  37. package/lib/replay/words.mjs +122 -0
  38. package/lib/repo-identity.mjs +81 -13
  39. package/lib/saved/checks.mjs +381 -0
  40. package/lib/saved/history.mjs +235 -0
  41. package/lib/saved/saver.mjs +111 -0
  42. package/lib/saved/store.mjs +256 -0
  43. package/lib/status.mjs +288 -0
  44. package/lib/validate.mjs +12 -3
  45. package/lib/view/assets/common.css +2 -0
  46. package/lib/view/assets/common.js +20 -2
  47. package/lib/view/assets/problems.js +4 -3
  48. package/lib/view/assets/replay.js +3 -1
  49. package/lib/view/assets/search.js +1 -1
  50. package/lib/view/assets/sessions.js +1 -0
  51. package/lib/view/assets/settings.html +16 -0
  52. package/lib/view/assets/settings.js +108 -4
  53. package/lib/view/assets/setup.html +7 -0
  54. package/lib/view/assets/setup.js +9 -0
  55. package/lib/view/codex-judge.mjs +1 -1
  56. package/lib/view/data.mjs +160 -76
  57. package/lib/view/own-week.mjs +119 -0
  58. package/lib/view/page-link.mjs +84 -0
  59. package/lib/view/problems-route.mjs +29 -3
  60. package/lib/view/replay-export.mjs +1 -1
  61. package/lib/view/selftest/clickthrough.js +9 -5
  62. package/lib/view/server.mjs +3 -1
  63. package/lib/view/settings.mjs +102 -34
  64. package/lib/view/setup.mjs +27 -14
  65. package/lib/view/suggest-words.mjs +67 -0
  66. package/lib/view.mjs +82 -133
  67. package/lib/worktrees.mjs +31 -18
  68. package/package.json +2 -1
@@ -0,0 +1,111 @@
1
+ // lib/saved/saver.mjs: `honestweek view`'s side of saved results (issue 151). One for the whole
2
+ // run, so a Settings save that reloads the week keeps its state.
3
+ //
4
+ // It saves only while the config view read has "saveResults" on, and only into the folder beside
5
+ // that config: never in the demo, and never before setup. What it last did, or why it couldn't,
6
+ // is kept for Settings to show, in a few words with no path.
7
+ //
8
+ // Zero runtime dependencies: Node built-ins only.
9
+
10
+ import { CHECKS_SUB, countsFromSaved, saveChecks } from './checks.mjs';
11
+ import { loadSaved, pruneHistory, saveHistory } from './history.mjs';
12
+ import { ensureGitignore } from '../init.mjs';
13
+ import { ensureSavedDir, forgetSaved, honestweekVersion, SAVED_GITIGNORE, savedDirOf, savedOptions, savedSize, withSavedLock } from './store.mjs';
14
+
15
+ /**
16
+ * createSaver({ configDir, config, demo, now }) -> { onChecked, load, counts, info, forget }
17
+ * configDir() the config's folder, or null when there's none to save beside
18
+ * config() the normalized config this run reads
19
+ */
20
+ export function createSaver({ configDir = () => null, config = () => null, demo = false, now = () => Date.now() } = {}) {
21
+ // forgotAt: when Forget last ran. A build that began reading before it saves nothing, so a
22
+ // save still pending when Forget is pressed can't bring the folder back.
23
+ const state = { savedAt: null, days: 0, error: null, forgotAt: null };
24
+ const dirOf = () => {
25
+ const c = demo ? null : configDir();
26
+ return typeof c === 'string' && c ? savedDirOf(c) : null;
27
+ };
28
+
29
+ /** The whole window's check results, from lib/view/data.mjs: saved when saving is on. */
30
+ function onChecked({ h, builtT = null, result, keys, describeStep }) {
31
+ const options = savedOptions(config());
32
+ const dir = dirOf();
33
+ if (!options || !dir) return null;
34
+ if (state.forgotAt !== null && !(Number.isFinite(builtT) && builtT > state.forgotAt)) return null;
35
+ try {
36
+ ensureSavedDir(dir);
37
+ ensureGitignore(configDir(), SAVED_GITIGNORE[0]);
38
+ const out = withSavedLock(dir, () => {
39
+ const checks = saveChecks({ dir, config: config(), h, result, keys, describeStep, keepDays: options.keepDays, builtT, now: now() });
40
+ if (options.history) saveHistory({ dir, config: config(), h, keepDays: options.keepDays, others: options.others, now: now() });
41
+ else pruneHistory({ dir, h, keepDays: options.keepDays, now: now() });
42
+ return checks;
43
+ }, now());
44
+ if (!out) {
45
+ state.error = 'another honestweek view was saving to the same folder, so this run saved nothing';
46
+ return null;
47
+ }
48
+ state.savedAt = now();
49
+ state.days = out.days.length;
50
+ state.error = null;
51
+ return out;
52
+ } catch (err) {
53
+ // The reason in a few words, with no path: a code such as EACCES says enough.
54
+ state.error = err?.code ? `the folder couldn't be written (${err.code})` : "the folder couldn't be written";
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The saved sessions a window can show and their saved findings ({ sessions, findings }, as
61
+ * loadSaved gives them), or null with saving or its history off, or nothing saved to read.
62
+ */
63
+ function load({ from, to, timezone, roots }) {
64
+ const options = savedOptions(config());
65
+ const dir = dirOf();
66
+ if (!options?.history || !dir) return null;
67
+ try {
68
+ const out = loadSaved({ dir, from, to, timezone, roots, config: config(), others: options.others, now: now() });
69
+ return out.sessions.length ? out : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** The trend's counts for an earlier window, from saved check results, or null to read it. */
76
+ function counts({ from, to, timezone, roots }) {
77
+ const dir = dirOf();
78
+ if (!savedOptions(config()) || !dir) return null;
79
+ try {
80
+ return countsFromSaved({ dir, from, to, timezone, roots, version: honestweekVersion(), config: config() });
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** What Settings shows: whether there's a folder, how much it holds, and the last save. */
87
+ function info() {
88
+ const dir = dirOf();
89
+ if (!dir) return { available: false, note: demo ? 'The demo saves nothing.' : 'Results are saved beside your config once there is one.' };
90
+ const size = savedSize(dir, CHECKS_SUB);
91
+ return { available: true, days: size.days, bytes: size.bytes, lastSavedAt: state.savedAt, error: state.error };
92
+ }
93
+
94
+ /** "Forget saved results": the whole folder, deleted. */
95
+ function forget() {
96
+ const dir = dirOf();
97
+ if (!dir) return { status: 409, body: { message: 'There are no saved results here to forget.' } };
98
+ try {
99
+ const r = forgetSaved(dir);
100
+ state.savedAt = null;
101
+ state.days = 0;
102
+ state.forgotAt = now();
103
+ const again = savedOptions(config()) ? ' Saving is still on, so the next window that loads is saved again.' : '';
104
+ return { status: 200, body: { forgotten: r.forgotten, message: `${r.forgotten ? 'Saved results deleted.' : 'There were no saved results to delete.'}${again}` } };
105
+ } catch (err) {
106
+ return { status: 500, body: { message: `The saved results couldn't all be deleted${err?.code ? ` (${err.code})` : ''}. Close anything using the folder and try again.` } };
107
+ }
108
+ }
109
+
110
+ return { onChecked, load, counts, info, forget };
111
+ }
@@ -0,0 +1,256 @@
1
+ // lib/saved/store.mjs: the folder `honestweek view` keeps its results in between runs, when the
2
+ // config's "saveResults" is on (issue 151).
3
+ //
4
+ // It sits beside the config as honestweek.saved/, readable only by its owner: the folder is made
5
+ // 0700 and every file 0600 where the system has those modes. It's git-ignored twice over, by a
6
+ // "*" .gitignore of its own and by a line in the config folder's .gitignore, the way
7
+ // honestweek.codex-judgments/ is. Every file is written whole, through a temporary file and a
8
+ // rename (lib/atomic-json.mjs), so a stopped run never leaves half a file.
9
+ //
10
+ // What's written has already passed the full redactor; what's read passes it again, with the
11
+ // settings of the run reading it (lib/saved/checks.mjs). Nothing here reads a log.
12
+ //
13
+ // With "saveResults" absent or off, nothing calls this module: no folder is made, read or
14
+ // changed, and every output stays as it was.
15
+ //
16
+ // Zero runtime dependencies: Node built-ins only.
17
+
18
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ import { atomicWriteJson, atomicWriteText } from '../atomic-json.mjs';
22
+ import { SAVE_KEEP_DAYS } from '../config.mjs';
23
+ import { pathKey } from '../replay/ids.mjs';
24
+ import { honestweekVersion, logIdHash, redactionPrint } from '../replay/saved-sessions.mjs';
25
+
26
+ /** The folder, beside the config, that holds saved results. */
27
+ export const SAVED_DIR = 'honestweek.saved';
28
+ /** The line `init` and Settings add to the config folder's .gitignore. */
29
+ export const SAVED_GITIGNORE = Object.freeze([`${SAVED_DIR}/`]);
30
+ /** The shape of every saved file; a file with another is read as missing. */
31
+ export const SAVED_SCHEMA = 1;
32
+ /** How long a saved day is kept, in days (the config's own limits). */
33
+ export const KEEP_DAYS = SAVE_KEEP_DAYS;
34
+ /** The largest saved file read back; a larger one is left alone and read as missing. */
35
+ export const MAX_SAVED_FILE = 64 * 1024 * 1024;
36
+
37
+ /** A saved day's file name: the day, then .json (check results) or .json.gz (history). */
38
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.json(\.gz)?$/;
39
+ const DAY_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
40
+ const shift = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
41
+
42
+ /** The saving settings from a normalized config: { keepDays, history }, or null when saving is
43
+ * off. `history` is whether each day's history is saved too, not only the check results. */
44
+ export function savedOptions(config) {
45
+ const s = config?.saveResults;
46
+ if (!s || s.on !== true) return null;
47
+ return { keepDays: Number.isInteger(s.keepDays) ? s.keepDays : KEEP_DAYS.default, history: s.history !== false, others: s.otherSessions === true };
48
+ }
49
+
50
+ /** The saved folder for a config folder. */
51
+ export const savedDirOf = (configDir) => join(configDir, SAVED_DIR);
52
+
53
+ // honestweek's own version and the fingerprint of the private-word settings, the engine's own
54
+ // (lib/replay/saved-sessions.mjs), which compares them to decide what to redact again.
55
+ export { honestweekVersion, redactionPrint };
56
+
57
+
58
+ /** A log's own session id (a Claude Code file id, a Codex thread id) as it's saved: a hash only. */
59
+ // The engine's own (lib/replay/saved-sessions.mjs), so the check results and the history name a
60
+ // log the same way.
61
+ export const idHashOf = logIdHash;
62
+
63
+ /** Make the folder, owner-only and ignored by its own .gitignore. The caller adds the config
64
+ * folder's line (lib/saved/saver.mjs), since init.mjs imports this file. */
65
+ export function ensureSavedDir(dir) {
66
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ if (process.platform !== 'win32') chmodSync(dir, 0o700);
68
+ const own = join(dir, '.gitignore');
69
+ if (!existsSync(own)) writeFileSync(own, '*\n', { mode: 0o600 });
70
+ }
71
+
72
+ /** Write one saved file's bytes (a gzipped day), making its folder (owner-only) first. */
73
+ export function writeSavedBytes(dir, parts, bytes) {
74
+ const folder = join(dir, ...parts.slice(0, -1));
75
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
76
+ if (process.platform !== 'win32') chmodSync(folder, 0o700);
77
+ atomicWriteText(join(folder, parts.at(-1)), bytes);
78
+ }
79
+
80
+ /** Write one saved file, making its folder (owner-only) first. */
81
+ export function writeSaved(dir, parts, value) {
82
+ const folder = join(dir, ...parts.slice(0, -1));
83
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
84
+ if (process.platform !== 'win32') chmodSync(folder, 0o700);
85
+ atomicWriteJson(join(folder, parts.at(-1)), value);
86
+ }
87
+
88
+ /** One saved file, or null when it's missing, too big, unreadable, or of another shape. */
89
+ export function readSaved(dir, parts) {
90
+ const path = join(dir, ...parts);
91
+ try {
92
+ const st = statSync(path);
93
+ if (!st.isFile() || st.size > MAX_SAVED_FILE) return null;
94
+ const value = JSON.parse(readFileSync(path, 'utf8'));
95
+ return value && typeof value === 'object' && value.schema === SAVED_SCHEMA ? value : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** The days a saved sub-folder holds, oldest first. */
102
+ export function savedDays(dir, sub) {
103
+ let names = [];
104
+ try {
105
+ names = readdirSync(join(dir, sub));
106
+ } catch {
107
+ return [];
108
+ }
109
+ return [...new Set(names.map((n) => DAY_FILE.exec(n)?.[1]).filter(Boolean))].sort();
110
+ }
111
+
112
+ /** The oldest day `keepDays` keeps on `today`, or null when either is malformed. */
113
+ export function keptFrom(today, keepDays) {
114
+ if (!DAY_SHAPE.test(today ?? '') || !Number.isInteger(keepDays) || keepDays < 1) return null;
115
+ return shift(today, -(keepDays - 1));
116
+ }
117
+
118
+ /**
119
+ * pruneSaved(dir, sub, { keepDays, today }) -> the days deleted
120
+ * Deletes the days of `sub` older than `keepDays` days before `today`: a day `keepDays - 1`
121
+ * days back is the oldest kept.
122
+ */
123
+ export function pruneSaved(dir, sub, { keepDays, today }) {
124
+ const oldest = keptFrom(today, keepDays);
125
+ if (!oldest) return [];
126
+ const gone = [];
127
+ for (const day of savedDays(dir, sub)) {
128
+ if (day >= oldest) continue;
129
+ for (const name of [`${day}.json`, `${day}.json.gz`]) rmSync(join(dir, sub, name), { force: true });
130
+ gone.push(day);
131
+ }
132
+ return gone;
133
+ }
134
+
135
+ /** How much the folder holds: { days, bytes, files }, with days counted in `sub`. */
136
+ export function savedSize(dir, sub) {
137
+ let bytes = 0;
138
+ let files = 0;
139
+ const walk = (p, depth) => {
140
+ let entries = [];
141
+ try {
142
+ entries = readdirSync(p, { withFileTypes: true });
143
+ } catch {
144
+ return;
145
+ }
146
+ for (const e of entries) {
147
+ const q = join(p, e.name);
148
+ if (e.isDirectory() && depth < 3) walk(q, depth + 1);
149
+ else if (e.isFile() && e.name !== '.gitignore') {
150
+ try {
151
+ bytes += statSync(q).size;
152
+ files += 1;
153
+ } catch {
154
+ /* gone since the listing */
155
+ }
156
+ }
157
+ }
158
+ };
159
+ walk(dir, 0);
160
+ return { days: savedDays(dir, sub).length, bytes, files };
161
+ }
162
+
163
+ /**
164
+ * forgetSaved(dir) -> { forgotten: boolean }
165
+ * Deletes the whole saved folder. Only a real folder named SAVED_DIR is deleted: a link, a file,
166
+ * or a folder of another name is left as it is.
167
+ */
168
+ export function forgetSaved(dir) {
169
+ if (!dir || !dir.replace(/[\\/]+$/, '').endsWith(SAVED_DIR)) return { forgotten: false };
170
+ let st;
171
+ try {
172
+ st = lstatSync(dir);
173
+ } catch {
174
+ return { forgotten: false };
175
+ }
176
+ if (!st.isDirectory() || st.isSymbolicLink()) return { forgotten: false };
177
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
178
+ return { forgotten: !existsSync(dir) };
179
+ }
180
+
181
+ /**
182
+ * logPaths(roots) -> Map of pathKey -> path
183
+ * Every .jsonl log file under the roots, by the hash a saved session names its files by, so a
184
+ * saved session can be matched to its files without its paths being saved.
185
+ */
186
+ export function logPaths(roots) {
187
+ const out = new Map();
188
+ const walk = (dir, depth) => {
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(dir, { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+ for (const e of entries) {
196
+ const p = join(dir, e.name);
197
+ if (e.isDirectory()) {
198
+ if (depth < 8) walk(p, depth + 1);
199
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) out.set(pathKey(p), p);
200
+ }
201
+ };
202
+ for (const r of [...(roots?.claude ?? []), ...(roots?.codex ?? [])]) if (r) walk(r, 0);
203
+ return out;
204
+ }
205
+
206
+ /**
207
+ * logKeys(roots) -> Set of pathKey
208
+ * Every .jsonl log file under the roots, as the hash a saved session names its files by, so a
209
+ * saved session can tell whether its log is still on disk without the path being saved.
210
+ */
211
+ export function logKeys(roots) {
212
+ return new Set(logPaths(roots).keys());
213
+ }
214
+
215
+ /** A lock older than this is left over from a run that stopped mid-save, and is taken over. */
216
+ export const LOCK_STALE_MS = 10 * 60 * 1000;
217
+
218
+ /**
219
+ * withSavedLock(dir, fn, now) -> fn's result, or null when another run holds the lock
220
+ * Two `honestweek view` runs on one config folder save one at a time: each day is read, merged and
221
+ * written whole, so two at once could drop a session only one of them read. The lock is a file in
222
+ * the folder, made only if it isn't there; one older than LOCK_STALE_MS is taken over.
223
+ */
224
+ export function withSavedLock(dir, fn, now = Date.now()) {
225
+ const path = join(dir, '.lock');
226
+ const take = () => {
227
+ try {
228
+ const fd = openSync(path, 'wx', 0o600);
229
+ writeSync(fd, String(now));
230
+ closeSync(fd);
231
+ return true;
232
+ } catch (err) {
233
+ if (err?.code !== 'EEXIST') throw err;
234
+ return false;
235
+ }
236
+ };
237
+ let held = take();
238
+ if (!held) {
239
+ let at = NaN;
240
+ try {
241
+ at = Number(readFileSync(path, 'utf8'));
242
+ } catch {
243
+ /* gone since: try again */
244
+ }
245
+ if (!Number.isFinite(at) || now - at > LOCK_STALE_MS) {
246
+ rmSync(path, { force: true });
247
+ held = take();
248
+ }
249
+ }
250
+ if (!held) return null;
251
+ try {
252
+ return fn();
253
+ } finally {
254
+ rmSync(path, { force: true });
255
+ }
256
+ }
package/lib/status.mjs ADDED
@@ -0,0 +1,288 @@
1
+ // lib/status.mjs: `honestweek status`, where the weekly summary stands, read-only (issue 183).
2
+ //
3
+ // The weekly skill loads this before its first step, so it can start from where things are
4
+ // instead of from init. It reports which config it found and where (the lookup from #177), the
5
+ // last completed week, whether the draft and items exist and which week each covers, whether the
6
+ // items pass validate's item gate, whether the output is built, when, and which week it covers
7
+ // (worked out from the items it was built after, since the output carries no week status can
8
+ // read), and the next step as a command in the form the user ran honestweek. A client config
9
+ // goes to the client flow, and a draft for another week is a question, never an overwrite.
10
+ //
11
+ // It always exits 0: a missing or broken file is a line in the report, never a failure, since a
12
+ // failing command aborts a skill that loads it. It writes nothing and runs no git. It prints names,
13
+ // weeks, counts and states only: never an item's text or anything from a session.
14
+
15
+ import { existsSync, readFileSync, statSync } from 'node:fs';
16
+ import { isAbsolute, join, resolve } from 'node:path';
17
+
18
+ import { loadConfig } from './config.mjs';
19
+ import { configAgain, configSourceWords, findConfig, takeConfigFlag } from './config-lookup.mjs';
20
+ import { isReservedDigestItem } from './digest-schema.mjs';
21
+ import { currentCommand } from './invocation.mjs';
22
+ import { localDateInTimezone, resolveWeek } from './resolve-week.mjs';
23
+ import { validateItems } from './validate.mjs';
24
+
25
+ const DRAFT_FILE = 'honestweek.draft.json';
26
+ const ITEMS_FILE = 'honestweek.items.json';
27
+
28
+ const day = (v) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : null);
29
+ const weekOf = (w) => {
30
+ const start = day(w?.start ?? w?.weekStart);
31
+ const end = day(w?.end ?? w?.weekEnd);
32
+ return start && end ? { start, end } : null;
33
+ };
34
+ const sameWeek = (a, b) => !!a && !!b && a.start === b.start && a.end === b.end;
35
+ const span = (w) => `${w.start} to ${w.end}`;
36
+ /** The first line of an error, so a report never carries a file's contents. */
37
+ const firstLine = (err) => String(err?.message ?? err).split('\n')[0].slice(0, 200);
38
+ const mtime = (path) => {
39
+ try {
40
+ return statSync(path).mtimeMs;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Why a config can't be loaded, without anything from the file: a JSON parser's message quotes
48
+ * the text around the fault, and some field checks quote the value they rejected.
49
+ */
50
+ function configProblem(err) {
51
+ const msg = firstLine(err);
52
+ if (/is not valid JSON/.test(msg)) return 'not valid JSON';
53
+ if (/file not found at|could not read/.test(msg)) return "it can't be read";
54
+ const field = /"([A-Za-z][\w.[\]]*)"/.exec(msg);
55
+ return field ? `"${field[1]}" isn't valid` : "it doesn't pass the config checks";
56
+ }
57
+
58
+ /** A JSON file's parsed value, or { error } with a one-line reason. */
59
+ function readJson(path) {
60
+ try {
61
+ return { value: JSON.parse(readFileSync(path, 'utf8')) };
62
+ } catch (err) {
63
+ return { error: err instanceof SyntaxError ? 'not valid JSON' : firstLine(err) };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * statusOf({ cwd, argv, now, lookup, command }) -> the report as a plain object.
69
+ * `lookup` is the config lookup's machine context ({ env, home }); the entry point sets it.
70
+ */
71
+ export function statusOf({ cwd = process.cwd(), argv = [], now = new Date(), lookup, command = currentCommand() } = {}) {
72
+ const report = { config: null, week: null, draft: null, items: null, output: null, next: null };
73
+
74
+ const flag = takeConfigFlag(argv);
75
+ // A next command repeats --config, so it reads the same config this report did.
76
+ const run = (step) => [command, step, ...configAgain(flag.config)].join(' ');
77
+ if (flag.error) {
78
+ report.config = { found: false, error: flag.error };
79
+ report.next = { step: 'fix-flag', says: flag.error };
80
+ return report;
81
+ }
82
+ const found = findConfig({ cwd, flag: flag.config, ...(lookup ? { lookup } : {}) });
83
+ report.config = { found: found.exists, path: found.path, source: found.source, where: configSourceWords(found.source) };
84
+ if (!found.exists) {
85
+ if (found.source === 'env' || found.source === 'flag') {
86
+ report.config.error = `${found.path} isn't there`;
87
+ report.next = { step: 'fix-config', says: `Fix the path ${found.source === 'env' ? 'HONESTWEEK_CONFIG' : '--config'} names, or remove it.` };
88
+ } else {
89
+ report.next = { step: 'init', command: run('init'), says: `No config yet. ${run('init')} writes one here from your git settings, or ${run('view')} sets one up in your browser.` };
90
+ }
91
+ return report;
92
+ }
93
+
94
+ let config;
95
+ try {
96
+ config = loadConfig(found.path);
97
+ } catch (err) {
98
+ report.config.readable = false;
99
+ report.config.error = configProblem(err);
100
+ report.next = { step: 'fix-config', says: `Fix ${found.path} (${run('validate')} says what's wrong), or move it away to start fresh.` };
101
+ return report;
102
+ }
103
+ report.config.readable = true;
104
+ const dir = found.dir;
105
+
106
+ const timezone = config.week?.timezone || 'UTC';
107
+ try {
108
+ const { weekStart, weekEnd } = resolveWeek({ today: localDateInTimezone(now, timezone) });
109
+ report.week = { start: weekStart.toISOString().slice(0, 10), end: weekEnd.toISOString().slice(0, 10), timezone };
110
+ } catch (err) {
111
+ report.week = { error: firstLine(err), timezone };
112
+ }
113
+
114
+ // The draft discover writes, and the week it covers.
115
+ const draftPath = resolve(dir, DRAFT_FILE);
116
+ report.draft = { file: DRAFT_FILE, exists: existsSync(draftPath) };
117
+ if (report.draft.exists) {
118
+ const d = readJson(draftPath);
119
+ if (d.error) report.draft.error = d.error;
120
+ else {
121
+ report.draft.week = weekOf(d.value?.week);
122
+ report.draft.sessions = Array.isArray(d.value?.sessions) ? d.value.sessions.length : null;
123
+ report.draft.current = sameWeek(report.draft.week, report.week);
124
+ }
125
+ }
126
+
127
+ // The items the skill writes, which week they name, and whether they pass the item gate.
128
+ const itemsPath = resolve(dir, ITEMS_FILE);
129
+ report.items = { file: ITEMS_FILE, exists: existsSync(itemsPath) };
130
+ if (report.items.exists) {
131
+ const it = readJson(itemsPath);
132
+ if (it.error) report.items.error = it.error;
133
+ else {
134
+ const parsed = it.value;
135
+ const items = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.items) ? parsed.items : [];
136
+ report.items.count = items.length;
137
+ // build's order: a client report's `period`, else `week`.
138
+ report.items.week = Array.isArray(parsed) ? null : weekOf(parsed?.period ?? parsed?.week);
139
+ try {
140
+ const gate = validateItems(items, config);
141
+ const reserved = items.filter(isReservedDigestItem).length;
142
+ report.items.passes = gate.ok && reserved === 0;
143
+ report.items.problems = gate.problems.length + reserved;
144
+ } catch {
145
+ // An entry the gate can't even read (a null, a string) is still a problem, not a crash.
146
+ report.items.passes = false;
147
+ report.items.problems = items.filter((i) => !i || typeof i !== 'object').length || 1;
148
+ }
149
+ const draftAt = mtime(draftPath);
150
+ const itemsAt = mtime(itemsPath);
151
+ report.items.olderThanDraft = draftAt !== null && itemsAt !== null && itemsAt < draftAt;
152
+ }
153
+ }
154
+
155
+ // The output build writes. A site build writes the artifact its JSON adapter names, beside the
156
+ // config; a transform adapter is code, which status doesn't run, so that output stays unknown.
157
+ const mode = config.output?.mode ?? null;
158
+ let outFile = config.output?.file ?? null;
159
+ if (mode === 'site') outFile = siteArtifact(config.output?.adapter);
160
+ const outPath = outFile ? (isAbsolute(outFile) ? outFile : resolve(dir, outFile)) : null;
161
+ report.output = { file: outFile, mode, exists: !!outPath && existsSync(outPath) };
162
+ if (mode === 'site' && !outFile) report.output.unknown = true;
163
+ // page and site output with no goals registry take the balanced digest too (flows/weekly.md).
164
+ report.output.digest = (mode === 'page' || mode === 'site') && !existsSync(join(dir, 'honestweek.objectives.json'));
165
+ if (report.output.exists) {
166
+ const outAt = mtime(outPath);
167
+ report.output.builtAt = outAt ? new Date(outAt).toISOString() : null;
168
+ const itemsAt = report.items.exists ? mtime(itemsPath) : null;
169
+ report.output.olderThanItems = outAt !== null && itemsAt !== null && outAt < itemsAt;
170
+ // The output names no week status can read, so its week is derived, never read from the file:
171
+ // built after these items, it came from them, and covers the week they name, else the week
172
+ // build picks for items that name none, the last completed week on the day it ran.
173
+ if (outAt !== null && report.items.exists && !report.items.error && !report.output.olderThanItems) {
174
+ if (report.items.week) report.output.week = { ...report.items.week, from: 'items' };
175
+ else {
176
+ try {
177
+ const { weekStart, weekEnd } = resolveWeek({ today: localDateInTimezone(new Date(outAt), timezone) });
178
+ report.output.week = { start: weekStart.toISOString().slice(0, 10), end: weekEnd.toISOString().slice(0, 10), from: 'build-day' };
179
+ } catch {
180
+ /* no week to derive */
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ report.next = mode === 'client' ? clientStep(report, run) : nextStep(report, run);
187
+ return report;
188
+ }
189
+
190
+ /** The client flow's next step: the period's history, the items, then validate and build. */
191
+ function clientStep(r, run) {
192
+ if (!r.items.exists || r.items.error) {
193
+ const why = !r.items.exists ? 'No client items yet.' : `The items file can't be read (${r.items.error}).`;
194
+ return { step: 'history', command: run('history --from <YYYY-MM-DD> --to <YYYY-MM-DD>'), says: `${why} Run ${run('history --from <YYYY-MM-DD> --to <YYYY-MM-DD>')} for the period, then write ${ITEMS_FILE} with its "period", as flows/client.md says.` };
195
+ }
196
+ if (!r.items.week) return { step: 'distil', says: `${ITEMS_FILE} names no "period"; a client report needs one, as flows/client.md says.` };
197
+ return nextAfterItems(r, run);
198
+ }
199
+
200
+ /** The artifact path a JSON site adapter names, or null when it can't be read without running code. */
201
+ function siteArtifact(adapter) {
202
+ if (typeof adapter !== 'string' || /\.(?:mjs|cjs|js)$/.test(adapter)) return null;
203
+ const a = readJson(adapter);
204
+ return typeof a.value?.artifact === 'string' && a.value.artifact ? a.value.artifact : null;
205
+ }
206
+
207
+ /** The next step of the weekly flow, from the report. */
208
+ function nextStep(r, run) {
209
+ if (!r.draft.exists || r.draft.error) {
210
+ const why = !r.draft.exists ? 'No draft yet.' : `The draft can't be read (${r.draft.error}).`;
211
+ return { step: 'discover', command: run('discover'), says: `${why} ${run('discover')} writes the last completed week's draft.` };
212
+ }
213
+ if (!r.draft.current) {
214
+ // A draft for another week may be one someone made on purpose (discover --week), or a week
215
+ // still in progress when the next one completed, so this asks rather than overwrite it.
216
+ const drafted = r.draft.week ? span(r.draft.week) : 'no week';
217
+ const latest = r.week?.start ? ` (${span(r.week)})` : '';
218
+ // No ready-to-run command here: running discover would write over the draft before anyone chose.
219
+ return { step: 'choose-week', says: `The draft covers ${drafted}, not the last completed week${latest}. Ask which week the user means: for the last completed week, ${run('discover')} writes a new draft over this one; to carry on with ${drafted}, go on from DISTIL.` };
220
+ }
221
+ if (!r.items.exists || r.items.error || r.items.olderThanDraft) {
222
+ const why = !r.items.exists ? 'No items yet.' : r.items.error ? `The items file can't be read (${r.items.error}).` : 'The items are older than the draft.';
223
+ return { step: 'distil', says: `${why} Distil the draft into ${ITEMS_FILE}, the skill's DISTIL step.` };
224
+ }
225
+ // build reads the week the items name, so items for another week would build that week, not the draft's.
226
+ if (r.items.week && !sameWeek(r.items.week, r.draft.week)) {
227
+ return { step: 'distil', says: `The items name ${span(r.items.week)}, but the draft covers ${r.draft.week ? span(r.draft.week) : 'no week'}. Distil this draft into ${ITEMS_FILE}, the skill's DISTIL step.` };
228
+ }
229
+ return nextAfterItems(r, run);
230
+ }
231
+
232
+ /** From items that exist and can be read: validate, build, then review. */
233
+ function nextAfterItems(r, run) {
234
+ if (!r.items.passes) {
235
+ return { step: 'validate', command: run('validate'), says: `${r.items.problems} item problem(s). Run ${run('validate')} to see them, fix ${ITEMS_FILE}, then build.` };
236
+ }
237
+ const digest = r.output.digest ? `${run('digest prepare')}, then ` : '';
238
+ if (r.output.unknown) {
239
+ return { step: 'build', command: run('build'), says: `The site adapter is code, so status can't see the output. If you haven't built since the items changed, run ${digest}${run('validate')}, then ${run('build')}.` };
240
+ }
241
+ if (!r.output.exists || r.output.olderThanItems) {
242
+ return { step: 'build', command: run('build'), says: `${!r.output.exists ? 'Not built yet.' : 'The output is older than the items.'} Run ${digest}${run('validate')}, then ${run('build')}.` };
243
+ }
244
+ return { step: 'review', command: run('preview'), says: `Built. Review it with the user; ${run('preview')} opens it in their browser.` };
245
+ }
246
+
247
+ /** The report as lines of text. */
248
+ export function statusText(r) {
249
+ const out = ['honestweek status (reads only, writes nothing)'];
250
+ const c = r.config;
251
+ if (!c?.found) out.push(` config: ${c?.error ?? 'none found (this folder, HONESTWEEK_CONFIG, ~/.honestweek/honestweek.config.json)'}`);
252
+ else if (c.readable === false) out.push(` config: ${c.path} (${c.where}) can't be read: ${c.error}`);
253
+ else out.push(` config: ${c.path} (${c.where})`);
254
+ if (r.week) out.push(r.week.error ? ` week: can't work out the last completed week: ${r.week.error}` : ` week: the last completed week is ${span(r.week)} (${r.week.timezone})`);
255
+ if (r.draft) {
256
+ const d = r.draft;
257
+ out.push(` draft: ${!d.exists ? `${d.file} not written yet` : d.error ? `${d.file} can't be read: ${d.error}` : `${d.file} covers ${d.week ? span(d.week) : 'no week'}${d.sessions !== null ? `, ${d.sessions} session(s)` : ''}${d.current ? '' : ' (not the last completed week)'}`}`);
258
+ }
259
+ if (r.items) {
260
+ const i = r.items;
261
+ let line;
262
+ if (!i.exists) line = `${i.file} not written yet`;
263
+ else if (i.error) line = `${i.file} can't be read: ${i.error}`;
264
+ else {
265
+ line = `${i.file}, ${i.count} item(s), ${i.week ? `week ${span(i.week)}` : 'no week named (build uses the last completed week)'}, ${i.passes ? 'pass the item gate' : `${i.problems} problem(s)`}`;
266
+ if (i.olderThanDraft) line += ', older than the draft';
267
+ }
268
+ out.push(` items: ${line}`);
269
+ }
270
+ if (r.output) {
271
+ const o = r.output;
272
+ out.push(` output: ${o.unknown ? "set by the site adapter, which status doesn't run" : !o.file ? 'no output file configured' : !o.exists ? `${o.file} not built yet` : `${o.file}, built ${o.builtAt}${o.week ? `, for ${span(o.week)} (${o.week.from === 'items' ? 'the week the items name' : 'the last completed week on the day it was built'})` : ''}${o.olderThanItems ? ', older than the items' : ''}`}`);
273
+ }
274
+ if (r.next) out.push(` next: ${r.next.says}`);
275
+ return `${out.join('\n')}\n`;
276
+ }
277
+
278
+ /** Entry point: prints the report and always exits 0. */
279
+ export default function run(argv = [], { cwd = process.cwd(), now = new Date(), out = (s) => process.stdout.write(s), lookup } = {}) {
280
+ let report;
281
+ try {
282
+ report = statusOf({ cwd, argv: argv.filter((a) => a !== '--json'), now, lookup });
283
+ } catch (err) {
284
+ report = { error: firstLine(err), next: { step: 'unknown', says: `status couldn't finish: ${firstLine(err)}` } };
285
+ }
286
+ out(argv.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : report.error ? `honestweek status: ${report.next.says}\n` : statusText(report));
287
+ return 0;
288
+ }