honestweek 0.0.0-stage → 0.2.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 (157) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +661 -2
  5. package/SKILL.md +129 -0
  6. package/bin/honestweek.mjs +240 -0
  7. package/honestweek.config.example.json +76 -0
  8. package/lib/archive.mjs +63 -0
  9. package/lib/atomic-json.mjs +42 -0
  10. package/lib/badges.mjs +43 -0
  11. package/lib/bounded-jsonl.mjs +39 -0
  12. package/lib/build.mjs +680 -0
  13. package/lib/carry-receipts.mjs +102 -0
  14. package/lib/carry-recovery.mjs +19 -0
  15. package/lib/claude-adapter.mjs +602 -0
  16. package/lib/client.mjs +397 -0
  17. package/lib/codex-records.mjs +167 -0
  18. package/lib/config.mjs +511 -0
  19. package/lib/curation-similarity.mjs +20 -0
  20. package/lib/demo/content.mjs +262 -0
  21. package/lib/demo/extra.mjs +1061 -0
  22. package/lib/demo/repo.mjs +385 -0
  23. package/lib/demo/week.mjs +2558 -0
  24. package/lib/digest-carry.mjs +538 -0
  25. package/lib/digest-curation.mjs +548 -0
  26. package/lib/digest-evidence.mjs +348 -0
  27. package/lib/digest-lifecycle.mjs +142 -0
  28. package/lib/digest-schema.mjs +58 -0
  29. package/lib/digest-source-bound.mjs +46 -0
  30. package/lib/digest-store.mjs +485 -0
  31. package/lib/digest.mjs +437 -0
  32. package/lib/discover.mjs +193 -0
  33. package/lib/emit/_shared.mjs +122 -0
  34. package/lib/emit/changelog.mjs +62 -0
  35. package/lib/emit/client.mjs +311 -0
  36. package/lib/emit/digest.mjs +53 -0
  37. package/lib/emit/goals-page.mjs +416 -0
  38. package/lib/emit/index.mjs +155 -0
  39. package/lib/emit/page.mjs +423 -0
  40. package/lib/emit/post.mjs +22 -0
  41. package/lib/emit/report.mjs +51 -0
  42. package/lib/git.mjs +568 -0
  43. package/lib/goals.mjs +539 -0
  44. package/lib/handoffs.mjs +151 -0
  45. package/lib/harvest.mjs +162 -0
  46. package/lib/history.mjs +110 -0
  47. package/lib/init.mjs +576 -0
  48. package/lib/invocation.mjs +102 -0
  49. package/lib/loopback-host.mjs +16 -0
  50. package/lib/mine/corpus.mjs +641 -0
  51. package/lib/mine/detect.mjs +681 -0
  52. package/lib/mine/draft.mjs +262 -0
  53. package/lib/mine/ledger.mjs +189 -0
  54. package/lib/mine/rank.mjs +236 -0
  55. package/lib/mine.mjs +381 -0
  56. package/lib/preview.mjs +504 -0
  57. package/lib/private-words.mjs +31 -0
  58. package/lib/problems/catalog.json +5884 -0
  59. package/lib/problems/checks.mjs +1454 -0
  60. package/lib/problems/classify.mjs +910 -0
  61. package/lib/problems/context.mjs +448 -0
  62. package/lib/problems/drafts.mjs +57 -0
  63. package/lib/problems/fix-tests.mjs +270 -0
  64. package/lib/problems/index.mjs +332 -0
  65. package/lib/problems/scope.mjs +401 -0
  66. package/lib/prompt-adapters.mjs +229 -0
  67. package/lib/prompt-curation.mjs +71 -0
  68. package/lib/prompt-identity.mjs +18 -0
  69. package/lib/prompt-lane.mjs +84 -0
  70. package/lib/prompt-lock.mjs +23 -0
  71. package/lib/prompt-privacy.mjs +451 -0
  72. package/lib/prompt-store.mjs +111 -0
  73. package/lib/prompts.mjs +100 -0
  74. package/lib/reader.mjs +189 -0
  75. package/lib/readers/client.json +9 -0
  76. package/lib/readers/default.json +5 -0
  77. package/lib/redact.mjs +577 -0
  78. package/lib/redaction-patterns.mjs +993 -0
  79. package/lib/replay/assemble.mjs +523 -0
  80. package/lib/replay/classify.mjs +464 -0
  81. package/lib/replay/claude.mjs +937 -0
  82. package/lib/replay/codex-program.mjs +459 -0
  83. package/lib/replay/codex.mjs +908 -0
  84. package/lib/replay/evidence.mjs +78 -0
  85. package/lib/replay/goals.mjs +287 -0
  86. package/lib/replay/ids.mjs +54 -0
  87. package/lib/replay/index.mjs +690 -0
  88. package/lib/replay/jsonl.mjs +102 -0
  89. package/lib/replay/launch.mjs +164 -0
  90. package/lib/replay/lookup.mjs +374 -0
  91. package/lib/replay/metrics.mjs +51 -0
  92. package/lib/replay/outcomes.mjs +233 -0
  93. package/lib/replay/parse-common.mjs +281 -0
  94. package/lib/replay/sources.mjs +205 -0
  95. package/lib/replay/timeline.mjs +331 -0
  96. package/lib/replay/views.mjs +340 -0
  97. package/lib/repo-identity.mjs +101 -0
  98. package/lib/resolve-week.mjs +178 -0
  99. package/lib/site/adapter.mjs +168 -0
  100. package/lib/site/archive.mjs +48 -0
  101. package/lib/site/derive.mjs +443 -0
  102. package/lib/site/detect.mjs +138 -0
  103. package/lib/site/emit-site.mjs +94 -0
  104. package/lib/site/fact-fence.mjs +148 -0
  105. package/lib/site/inspect.mjs +102 -0
  106. package/lib/site/load-adapter.mjs +30 -0
  107. package/lib/site/sessions.mjs +274 -0
  108. package/lib/site/transform.mjs +114 -0
  109. package/lib/site/values.mjs +153 -0
  110. package/lib/site/week-grid.mjs +49 -0
  111. package/lib/validate.mjs +258 -0
  112. package/lib/view/assets/common.css +789 -0
  113. package/lib/view/assets/common.js +1007 -0
  114. package/lib/view/assets/evidence.js +64 -0
  115. package/lib/view/assets/facts.js +100 -0
  116. package/lib/view/assets/form.js +211 -0
  117. package/lib/view/assets/goal.html +92 -0
  118. package/lib/view/assets/goal.js +638 -0
  119. package/lib/view/assets/insights.js +188 -0
  120. package/lib/view/assets/key.js +355 -0
  121. package/lib/view/assets/prefs.js +250 -0
  122. package/lib/view/assets/private-text.js +41 -0
  123. package/lib/view/assets/problems.css +191 -0
  124. package/lib/view/assets/problems.html +75 -0
  125. package/lib/view/assets/problems.js +1069 -0
  126. package/lib/view/assets/replay-model.js +704 -0
  127. package/lib/view/assets/replay.css +306 -0
  128. package/lib/view/assets/replay.html +122 -0
  129. package/lib/view/assets/replay.js +2079 -0
  130. package/lib/view/assets/search.html +48 -0
  131. package/lib/view/assets/search.js +615 -0
  132. package/lib/view/assets/sessions.js +144 -0
  133. package/lib/view/assets/settings.html +99 -0
  134. package/lib/view/assets/settings.js +183 -0
  135. package/lib/view/assets/setup.html +96 -0
  136. package/lib/view/assets/setup.js +131 -0
  137. package/lib/view/assets/strip.js +465 -0
  138. package/lib/view/codex-judge.mjs +411 -0
  139. package/lib/view/data.mjs +1339 -0
  140. package/lib/view/facts.mjs +306 -0
  141. package/lib/view/insights.mjs +309 -0
  142. package/lib/view/leaks.mjs +252 -0
  143. package/lib/view/problems-route.mjs +425 -0
  144. package/lib/view/progressive.mjs +134 -0
  145. package/lib/view/replay-export.mjs +252 -0
  146. package/lib/view/selftest/clickthrough.html +32 -0
  147. package/lib/view/selftest/clickthrough.js +2678 -0
  148. package/lib/view/server.mjs +336 -0
  149. package/lib/view/settings.mjs +369 -0
  150. package/lib/view/setup.mjs +286 -0
  151. package/lib/view/window.mjs +204 -0
  152. package/lib/view/word-index.mjs +192 -0
  153. package/lib/view.mjs +572 -0
  154. package/lib/voice-fence.mjs +213 -0
  155. package/lib/windows-root.mjs +17 -0
  156. package/lib/worktrees.mjs +260 -0
  157. package/package.json +44 -4
@@ -0,0 +1,306 @@
1
+ // lib/view/facts.mjs: plain facts about each session, the ones Claude Code's /insights writes to
2
+ // its session-meta files, computed by honestweek itself from the logs it already read, for
3
+ // Claude Code and Codex alike. No model is asked and nothing is read again: every fact comes
4
+ // from the work history's events and token counts.
5
+ //
6
+ // Each fact says how it's known:
7
+ // recorded a record holds it as it stands (a start time, a prompt's time)
8
+ // derived counted or computed from recorded values only (a count, a sum, a gap)
9
+ // inferred a named pattern produced it and could be wrong (a "risky" command)
10
+ // not-recorded this agent's logs, as honestweek reads them, don't carry it; never shown as 0
11
+ // hidden recorded, but not shown: the folder of a session outside the configured repos
12
+ //
13
+ // Every fact carries its receipt: the ids of the steps it was read or counted from (`basis`),
14
+ // or, for token counts, the log lines they came from (`refs`), and an inferred one names its rule.
15
+ // Facts are numbers, names and times, plus two strings (the first prompt and the folder),
16
+ // which the caller passes through the page's redactor before they're shown.
17
+
18
+ import { mcpServer } from '../replay/classify.mjs';
19
+ import { editedFileKeys } from '../replay/metrics.mjs';
20
+
21
+ /** The facts, in the order /insights writes them, with the short label a page shows. */
22
+ export const FACTS = Object.freeze([
23
+ ['session_id', 'Session id'],
24
+ ['project_path', 'Folder'],
25
+ ['start_time', 'Started'],
26
+ ['duration_minutes', 'Minutes'],
27
+ ['user_message_count', 'Your messages'],
28
+ ['assistant_message_count', 'Agent messages'],
29
+ ['tool_counts', 'Tools'],
30
+ ['languages', 'Languages'],
31
+ ['git_commits', 'Commits run'],
32
+ ['git_pushes', 'Pushes run'],
33
+ ['input_tokens', 'Input tokens'],
34
+ ['output_tokens', 'Output tokens'],
35
+ ['first_prompt', 'First prompt'],
36
+ ['user_interruptions', 'Interruptions'],
37
+ ['user_response_times', 'Your reply time'],
38
+ ['tool_errors', 'Tool errors'],
39
+ ['tool_error_categories', 'Errors by tool'],
40
+ ['uses_task_agent', 'Sub-agents'],
41
+ ['uses_mcp', 'MCP tools'],
42
+ ['uses_web_search', 'Web search'],
43
+ ['uses_web_fetch', 'Web fetch'],
44
+ ['lines_added', 'Lines added'],
45
+ ['lines_removed', 'Lines removed'],
46
+ ['files_modified', 'Files changed'],
47
+ ['message_hours', 'Hours you wrote'],
48
+ ['user_message_timestamps', 'Message times'],
49
+ ['bash_would_prompt_count', 'Would prompt'],
50
+ ['file_edit_tool_count', 'Edits'],
51
+ ['destructive_command_count', 'Risky commands'],
52
+ ]);
53
+ export const FACT_NAMES = Object.freeze(FACTS.map(([n]) => n));
54
+
55
+ /**
56
+ * What an agent's logs don't carry, as honestweek reads them. Neither log says whether a
57
+ * command would have asked for permission. Codex has no web fetch tool, and the engine doesn't
58
+ * read Codex's web search records.
59
+ */
60
+ const NOT_RECORDED = Object.freeze({
61
+ 'claude-code': new Set(['bash_would_prompt_count']),
62
+ codex: new Set(['bash_would_prompt_count', 'uses_web_search', 'uses_web_fetch']),
63
+ });
64
+
65
+ /** The pattern behind destructive_command_count: commands that delete or rewrite. */
66
+ export const DESTRUCTIVE_RULE = 'facts.destructive-command';
67
+ /** The rule behind git_commits and git_pushes: a git commit or push in a command's text. */
68
+ export const GIT_RULE = 'facts.git-command';
69
+ /** The rule behind a Codex interruption count: a turn Codex records as interrupted. */
70
+ export const INTERRUPT_RULE = 'facts.codex-interrupted';
71
+ const DESTRUCTIVE = [
72
+ /\brm\s+(?:-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/,
73
+ /\bgit\s+reset\s+--hard\b/,
74
+ /\bgit\s+push\b[^;&|\n]*\s(?:--force(?:-with-lease)?|-f)\b/,
75
+ /\bgit\s+clean\s+-[a-zA-Z]*f/,
76
+ /\bgit\s+branch\s+-D\b/,
77
+ /\bgit\s+checkout\s+--\s+\./,
78
+ /\bRemove-Item\b[^;&|\n]*-Recurse/i,
79
+ /\b(?:rmdir|rd|del)\s+\/[sq]\b/i,
80
+ /\bdrop\s+(?:table|database)\b/i,
81
+ ];
82
+ const GIT_COMMIT = /\bgit\s+(?:-[cC]\s+\S+\s+)*commit\b/;
83
+ const GIT_PUSH = /\bgit\s+(?:-[cC]\s+\S+\s+)*push\b/;
84
+ const DID_NOT_RUN = new Set(['rejected', 'refused', 'interrupted']);
85
+
86
+ const LANGUAGE = Object.freeze({
87
+ js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript',
88
+ py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', swift: 'Swift', cs: 'C#', c: 'C', h: 'C',
89
+ cpp: 'C++', cc: 'C++', hpp: 'C++', php: 'PHP', sh: 'Shell', bash: 'Shell', ps1: 'PowerShell', sql: 'SQL',
90
+ md: 'Markdown', json: 'JSON', yml: 'YAML', yaml: 'YAML', toml: 'TOML', html: 'HTML', css: 'CSS', scss: 'CSS',
91
+ });
92
+ const languageOf = (file) => {
93
+ const m = typeof file === 'string' ? file.match(/\.([A-Za-z0-9]+)$/) : null;
94
+ return m ? LANGUAGE[m[1].toLowerCase()] ?? null : null;
95
+ };
96
+
97
+ /** The most step ids kept as one fact's receipt. */
98
+ export const MAX_BASIS = 200;
99
+ /** A fact: its value, how it's known, the steps it was read or counted from (its receipt),
100
+ * and, when inferred, the rule that decided it. */
101
+ const fact = (value, how, basis = [], rule = null) => ({ value, how, basis: basis.slice(0, MAX_BASIS), ...(rule ? { rule } : {}) });
102
+ const median = (xs) => {
103
+ if (!xs.length) return null;
104
+ const s = [...xs].sort((a, b) => a - b);
105
+ const mid = s.length >> 1;
106
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
107
+ };
108
+ const bump = (obj, k, n = 1) => {
109
+ obj[k] = (obj[k] ?? 0) + n;
110
+ };
111
+
112
+ /** The hour of day, 0 to 23, of an instant in `timezone`. */
113
+ function hourIn(t, timezone) {
114
+ const h = Number(new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', hourCycle: 'h23' }).format(new Date(t)));
115
+ return h === 24 ? 0 : h;
116
+ }
117
+
118
+ /**
119
+ * sessionFacts(h, session, { timezone, usage, id, cwd, hidden, events }) -> { key, agent, facts }
120
+ * `h` is a work history, `session` one of its sessions. `usage` is the token-count list
121
+ * (h.usage.calls, possibly from another build of the same window), `id` the session's own id
122
+ * (Claude Code's file id or Codex's thread id) or null, `cwd` its working folder or null, and
123
+ * `hidden` true for a session outside the configured repos, whose folder isn't shown. The
124
+ * two text facts, first_prompt and project_path, are returned as they are: the caller redacts.
125
+ */
126
+ export function sessionFacts(h, session, { timezone = 'UTC', usage = null, id = null, cwd = null, hidden = false, events = null } = {}) {
127
+ const agent = session.tool === 'codex' ? 'codex' : 'claude-code';
128
+ const mine = (events ?? h.events.filter((e) => e.session === session.key)).slice().sort((a, b) => a.t - b.t || (a.id < b.id ? -1 : 1));
129
+ const main = `${session.key}:main`;
130
+ const ids = (list) => list.map((e) => e.id);
131
+ const prompts = mine.filter((e) => e.kind === 'prompt');
132
+ const promptHow = prompts.some((e) => (e.inferred ?? []).some((x) => x.key === 'authorship')) ? 'inferred' : 'derived';
133
+ const messages = mine.filter((e) => e.kind === 'message' && e.agent === main);
134
+ const actions = mine.filter((e) => e.kind === 'action');
135
+ const tools = {};
136
+ const errorsByTool = {};
137
+ const languages = {};
138
+ const langSteps = [];
139
+ const errors = [];
140
+ const commits = [];
141
+ const pushes = [];
142
+ const destructive = [];
143
+ const edits = [];
144
+ const okEdits = [];
145
+ let added = 0;
146
+ let removed = 0;
147
+ const files = new Set();
148
+ for (const e of actions) {
149
+ const f = e.facts;
150
+ bump(tools, f.tool ?? 'other');
151
+ if (f.result === 'error') {
152
+ bump(errorsByTool, f.tool ?? 'other');
153
+ errors.push(e);
154
+ }
155
+ const lang = languageOf(f.file);
156
+ if (lang && (f.category === 'edit' || f.category === 'read')) {
157
+ bump(languages, lang);
158
+ langSteps.push(e);
159
+ }
160
+ if (f.category === 'edit') {
161
+ edits.push(e);
162
+ if (f.result === 'ok') {
163
+ okEdits.push(e);
164
+ const p = e.derived?.patch;
165
+ if (p) {
166
+ added += p.added ?? 0;
167
+ removed += p.removed ?? 0;
168
+ }
169
+ // A whole-file write records the lines it wrote, not a patch against the old file.
170
+ if (Number.isInteger(e.derived?.linesWritten) && !(p?.added || p?.removed)) added += e.derived.linesWritten;
171
+ for (const k of editedFileKeys(e)) files.add(k);
172
+ }
173
+ }
174
+ if (f.category === 'shell' && typeof f.command === 'string' && !DID_NOT_RUN.has(f.result)) {
175
+ if (GIT_COMMIT.test(f.command)) commits.push(e);
176
+ if (GIT_PUSH.test(f.command)) pushes.push(e);
177
+ if (DESTRUCTIVE.some((re) => re.test(f.command))) destructive.push(e);
178
+ }
179
+ }
180
+ // The person's interruptions: Claude Code records who stopped a run; Codex records that a
181
+ // turn was interrupted, not by whom, so its count is an inference.
182
+ const interrupts = mine.filter((e) => e.kind === 'interrupt');
183
+ const byPerson = interrupts.filter((e) => e.facts.by === 'person');
184
+ const codexInterrupted = interrupts.filter((e) => e.facts.by === 'harness-reported' && e.facts.reason === 'interrupted');
185
+ // Reply time: from the main agent's last recorded step to the person's next prompt.
186
+ const replies = [];
187
+ const replied = [];
188
+ let lastAgentT = null;
189
+ for (const e of mine) {
190
+ if (e.kind === 'prompt' && e.agent === main) {
191
+ if (lastAgentT != null && e.t >= lastAgentT) {
192
+ replies.push(Math.round((e.t - lastAgentT) / 1000));
193
+ replied.push(e);
194
+ }
195
+ lastAgentT = null;
196
+ } else if (e.agent === main && (e.kind === 'message' || e.kind === 'action' || e.kind === 'turn-end')) {
197
+ lastAgentT = Math.max(lastAgentT ?? 0, e.end?.t ?? e.t);
198
+ }
199
+ }
200
+ const hours = {};
201
+ for (const e of prompts) bump(hours, String(hourIn(e.t, timezone)));
202
+ const calls = (usage ?? []).filter((c) => c.session === session.key);
203
+ const first = prompts.find((e) => typeof e.facts.text === 'string');
204
+ const firstT = Date.parse(session.firstAt);
205
+ const lastT = Date.parse(session.lastAt);
206
+ const ends = mine.length ? [mine[0], mine[mine.length - 1]] : [];
207
+ // A count matched by a pattern on the command's text is inferred: the text says a git commit
208
+ // or an rm -rf was asked for, not that it did what the pattern names.
209
+ const matched = (list, rule) => (list.length ? fact(list.length, 'inferred', ids(list), rule) : fact(0, 'derived'));
210
+ const some = (pred) => {
211
+ const hit = actions.find(pred);
212
+ return fact(!!hit, 'derived', hit ? [hit.id] : []);
213
+ };
214
+
215
+ const out = {
216
+ session_id: id ? fact(id, 'recorded', ids(mine.slice(0, 1))) : fact(null, 'not-recorded'),
217
+ project_path: hidden ? fact(null, 'hidden') : cwd ? fact(cwd, 'recorded', ids(mine.slice(0, 1))) : fact(null, 'not-recorded'),
218
+ start_time: fact(session.firstAt ?? null, 'recorded', ids(mine.slice(0, 1))),
219
+ duration_minutes: fact(Number.isFinite(firstT) && Number.isFinite(lastT) ? Math.round((lastT - firstT) / 60000) : null, 'derived', ids(ends)),
220
+ user_message_count: fact(prompts.length, promptHow, ids(prompts), promptHow === 'inferred' ? 'authorship' : null),
221
+ assistant_message_count: fact(messages.length, 'derived', ids(messages)),
222
+ tool_counts: fact(tools, 'derived', ids(actions)),
223
+ languages: fact(languages, 'derived', ids(langSteps)),
224
+ git_commits: matched(commits, GIT_RULE),
225
+ git_pushes: matched(pushes, GIT_RULE),
226
+ input_tokens: calls.length ? { ...fact(calls.reduce((n, c) => n + (c.input ?? 0), 0), 'derived'), refs: calls.slice(0, MAX_BASIS).map((c) => ({ src: c.source, lines: c.lines })) } : fact(null, 'not-recorded'),
227
+ output_tokens: calls.length ? { ...fact(calls.reduce((n, c) => n + (c.output ?? 0), 0), 'derived'), refs: calls.slice(0, MAX_BASIS).map((c) => ({ src: c.source, lines: c.lines })) } : fact(null, 'not-recorded'),
228
+ first_prompt: fact(first ? first.facts.text : null, 'recorded', first ? [first.id] : []),
229
+ user_interruptions: agent === 'codex' ? (codexInterrupted.length ? fact(codexInterrupted.length, 'inferred', ids(codexInterrupted), INTERRUPT_RULE) : fact(0, 'derived')) : fact(byPerson.length, 'derived', ids(byPerson)),
230
+ user_response_times: fact(replies, 'derived', ids(replied)),
231
+ tool_errors: fact(errors.length, 'derived', ids(errors)),
232
+ tool_error_categories: fact(errorsByTool, 'derived', ids(errors)),
233
+ uses_task_agent: some((e) => e.facts.category === 'delegate'),
234
+ uses_mcp: some((e) => mcpServer(e.facts.tool) != null),
235
+ uses_web_search: some((e) => e.facts.tool === 'WebSearch'),
236
+ uses_web_fetch: some((e) => e.facts.tool === 'WebFetch'),
237
+ lines_added: fact(added, 'derived', ids(okEdits)),
238
+ lines_removed: fact(removed, 'derived', ids(okEdits)),
239
+ files_modified: fact(files.size, 'derived', ids(okEdits)),
240
+ message_hours: fact(hours, 'derived', ids(prompts)),
241
+ user_message_timestamps: fact(prompts.map((e) => e.at).filter((x) => typeof x === 'string'), 'recorded', ids(prompts)),
242
+ bash_would_prompt_count: fact(null, 'not-recorded'),
243
+ file_edit_tool_count: fact(edits.length, 'derived', ids(edits)),
244
+ destructive_command_count: matched(destructive, DESTRUCTIVE_RULE),
245
+ };
246
+ for (const name of NOT_RECORDED[agent]) out[name] = fact(null, 'not-recorded');
247
+ return { key: session.key, agent, facts: out };
248
+ }
249
+
250
+ /** The median reply time in seconds, the one figure a page shows for user_response_times. */
251
+ export const medianReply = (secs) => median(secs);
252
+
253
+ /**
254
+ * windowTotals(list) -> { all, 'claude-code', codex }
255
+ * Totals over sessions' facts: counts and sums added, true facts counted as sessions, maps
256
+ * merged. Each total says how many sessions it's known for, and which (`basis`, session keys); a fact no session records stays
257
+ * not-recorded, never 0. A total is inferred when any part of it is.
258
+ */
259
+ export function windowTotals(list) {
260
+ const empty = () => ({ sessions: 0, facts: {} });
261
+ const out = { all: empty(), 'claude-code': empty(), codex: empty() };
262
+ for (const s of list) {
263
+ for (const t of [out.all, out[s.agent]]) {
264
+ t.sessions++;
265
+ for (const name of FACT_NAMES) {
266
+ if (name === 'session_id' || name === 'project_path' || name === 'first_prompt' || name === 'start_time') continue;
267
+ const f = s.facts[name];
268
+ const cur = (t.facts[name] ??= { value: null, how: 'not-recorded', known: 0, basis: [] });
269
+ if (f.how === 'not-recorded' || f.value == null) continue;
270
+ cur.known++;
271
+ // The total's receipt: the sessions it adds up.
272
+ cur.basis.push(s.key);
273
+ if (cur.how === 'not-recorded') cur.how = 'derived';
274
+ if (f.how === 'inferred') cur.how = 'inferred';
275
+ const v = f.value;
276
+ if (typeof v === 'boolean') cur.value = (cur.value ?? 0) + (v ? 1 : 0);
277
+ else if (typeof v === 'number') cur.value = (cur.value ?? 0) + v;
278
+ else if (Array.isArray(v)) cur.value = [...(cur.value ?? []), ...v];
279
+ else if (v && typeof v === 'object') {
280
+ cur.value ??= {};
281
+ for (const [k, n] of Object.entries(v)) bump(cur.value, k, n);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ // The window keeps one figure per reply-time list and per timestamp list: the median, and the count.
287
+ for (const t of Object.values(out)) {
288
+ if (Array.isArray(t.facts.user_response_times?.value)) t.facts.user_response_times.value = median(t.facts.user_response_times.value);
289
+ if (Array.isArray(t.facts.user_message_timestamps?.value)) t.facts.user_message_timestamps.value = t.facts.user_message_timestamps.value.length;
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /**
295
+ * coverage(list) -> { [agent]: { sessions, facts: { [name]: known count } } }
296
+ * How many of each agent's sessions each fact is known for: the figure the pull request quotes.
297
+ */
298
+ export function coverage(list) {
299
+ const out = {};
300
+ for (const s of list) {
301
+ const c = (out[s.agent] ??= { sessions: 0, facts: Object.fromEntries(FACT_NAMES.map((n) => [n, 0])) });
302
+ c.sessions++;
303
+ for (const n of FACT_NAMES) if (s.facts[n].how !== 'not-recorded') c.facts[n]++;
304
+ }
305
+ return out;
306
+ }
@@ -0,0 +1,309 @@
1
+ // lib/view/insights.mjs: the optional "Include /insights" group on the Problems page, and the
2
+ // "Run /insights" button.
3
+ //
4
+ // Claude Code's own /insights command has the model on the person's account read their sessions
5
+ // and writes what it concluded to <claude folder>/usage-data/: facets/<session id>.json (the
6
+ // model's reading of one session: its friction, a summary) and session-meta/<session id>.json
7
+ // (counts about it). Their shapes aren't documented and may change, so they're read defensively:
8
+ // only files for sessions this window already loaded, by id, never a folder listing beyond
9
+ // whether any facets file exists; a file that's too large, doesn't parse, isn't an object or
10
+ // whose session_id isn't its own id counts as skipped; unknown fields are ignored. Nothing here
11
+ // runs git, and nothing is written.
12
+ //
13
+ // What /insights wrote is the model's words, so it's never mixed into honestweek's own counts,
14
+ // levels or ranking: the caller shows it as its own group, labelled AI-written, each item tied
15
+ // to a whole session rather than to steps, and every string passes the redactor first.
16
+ //
17
+ // The button runs the local `claude -p /insights`, found on the PATH (absolute folders only, so
18
+ // a claude file sitting in the current folder is never picked), one run at a time, only while
19
+ // the toggle is on, stopped after RUN_TIMEOUT_MS. claude gets only the environment variables on
20
+ // CLAUDE_ENV (claudeEnv below), not other tools' tokens. Its output is discarded: the page reads
21
+ // only the files it leaves behind. honestweek makes no network call itself; claude does, on the
22
+ // person's own plan.
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs';
26
+ import { dirname, isAbsolute, join } from 'node:path';
27
+ import { systemProgram } from '../windows-root.mjs';
28
+
29
+ /** The largest /insights file read; a bigger one is skipped. */
30
+ export const MAX_INSIGHTS_FILE = 1024 * 1024;
31
+ /** How long one run may take before it's stopped. */
32
+ export const RUN_TIMEOUT_MS = 15 * 60 * 1000;
33
+ /** The longest text kept from one /insights field, after redaction. */
34
+ export const INSIGHTS_TEXT = 400;
35
+ /** The most friction categories kept per session. */
36
+ const MAX_CATEGORIES = 20;
37
+
38
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
39
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
40
+
41
+ /**
42
+ * The environment variables any program the page starts keeps: what it needs to start, find its
43
+ * home, config and temporary folders, and reach its service through a proxy or a company
44
+ * certificate. Each program adds its own sign-in variables.
45
+ */
46
+ export const SYSTEM_ENV = Object.freeze(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'SYSTEMDRIVE', 'OS', 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432', 'COMMONPROGRAMFILES', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'ALL_PROXY', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS']);
47
+
48
+ /** `env` with only the string variables named in `names` (a Set of capitals) or starting with one of `prefixes`, matched in capitals. */
49
+ export function keepEnv(env, names, prefixes = []) {
50
+ return Object.fromEntries(
51
+ Object.entries(env ?? {}).filter(([k, v]) => {
52
+ const up = k.toUpperCase();
53
+ return typeof v === 'string' && (names.has(up) || prefixes.some((p) => up.startsWith(p)));
54
+ }),
55
+ );
56
+ }
57
+
58
+ /**
59
+ * What claude keeps besides SYSTEM_ENV. CLAUDE_CONFIG_DIR and the home folder find its sign-in
60
+ * file (on macOS, the login keychain, which needs no variable); ANTHROPIC_ and CLAUDE_CODE_ hold an
61
+ * API key, a sign-in token, a gateway address and its own settings; AWS_ and the Google Cloud names
62
+ * sign in through Amazon Bedrock or Google Vertex AI; DISABLE_ and DO_NOT_TRACK keep the person's
63
+ * opt-outs; the version managers' homes let a claude installed with npm find its node. Everything
64
+ * else this process holds (GitHub, npm or OpenAI tokens, honestweek's own settings) is left out,
65
+ * so a session that talks claude into printing its environment finds little there.
66
+ */
67
+ const CLAUDE_ENV = new Set([...SYSTEM_ENV, 'CLAUDE_CONFIG_DIR', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'CLOUD_ML_REGION', 'CLOUDSDK_CONFIG', 'DO_NOT_TRACK', 'NVM_DIR', 'VOLTA_HOME', 'ASDF_DIR', 'ASDF_DATA_DIR']);
68
+ const CLAUDE_PREFIXES = Object.freeze(['ANTHROPIC_', 'CLAUDE_CODE_', 'AWS_', 'VERTEX_REGION_', 'DISABLE_']);
69
+ export const claudeEnv = (env) => keepEnv(env, CLAUDE_ENV, CLAUDE_PREFIXES);
70
+
71
+ /** Where /insights writes, beside Claude Code's projects folder: <claude folder>/usage-data. */
72
+ export function insightsDir(roots) {
73
+ const projects = roots?.claude?.[0];
74
+ return typeof projects === 'string' && projects ? join(dirname(projects), 'usage-data') : null;
75
+ }
76
+
77
+ /** Whether any facets file exists. Only names are listed; no file is read. */
78
+ export function hasInsightsData(dir) {
79
+ if (!dir) return false;
80
+ try {
81
+ return readdirSync(join(dir, 'facets')).some((n) => n.toLowerCase().endsWith('.json'));
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** One /insights file for one session: { state: 'absent' } | { state: 'skipped' } | { state: 'ok', value }. */
88
+ function readOne(dir, folder, id) {
89
+ const file = join(dir, folder, `${id}.json`);
90
+ let st;
91
+ try {
92
+ st = statSync(file);
93
+ } catch {
94
+ return { state: 'absent' };
95
+ }
96
+ if (!st.isFile() || st.size > MAX_INSIGHTS_FILE) return { state: 'skipped' };
97
+ let value;
98
+ try {
99
+ value = JSON.parse(readFileSync(file, 'utf8'));
100
+ } catch {
101
+ return { state: 'skipped' };
102
+ }
103
+ if (!isObject(value) || typeof value.session_id !== 'string' || value.session_id.toLowerCase() !== id) return { state: 'skipped' };
104
+ return { state: 'ok', value };
105
+ }
106
+
107
+ /** A text field as one string: a string, or a list of strings joined; anything else is null. */
108
+ function textOf(v) {
109
+ if (typeof v === 'string') return v.trim() || null;
110
+ if (Array.isArray(v)) {
111
+ const parts = v.filter((x) => typeof x === 'string' && x.trim());
112
+ return parts.length ? parts.join(' ') : null;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** friction_counts as [{ category, count }]: whole counts of 1 or more, largest first. */
118
+ function frictionsOf(v) {
119
+ if (!isObject(v)) return [];
120
+ return Object.entries(v)
121
+ .filter(([k, n]) => k.trim() && k.length <= 200 && Number.isInteger(n) && n >= 1)
122
+ .map(([category, count]) => ({ category, count }))
123
+ .sort((a, b) => b.count - a.count || (a.category < b.category ? -1 : 1))
124
+ .slice(0, MAX_CATEGORIES);
125
+ }
126
+
127
+ /**
128
+ * readInsights({ dir, sessions }) -> { exists, sessions, matched, skipped, items }
129
+ * `sessions` are [{ key, id }]: an engine session key and its Claude Code session id. Only
130
+ * those sessions' files are read. `matched` counts sessions with a readable facets file,
131
+ * `skipped` the files left out, and `items` one entry per matched session with friction.
132
+ * The strings aren't redacted yet.
133
+ */
134
+ export function readInsights({ dir, sessions }) {
135
+ const out = { exists: hasInsightsData(dir), sessions: 0, matched: 0, skipped: 0, items: [] };
136
+ if (!dir) return out;
137
+ const seen = new Set();
138
+ for (const s of sessions ?? []) {
139
+ const id = typeof s?.id === 'string' ? s.id.toLowerCase() : '';
140
+ if (!SESSION_ID.test(id) || seen.has(id)) continue;
141
+ seen.add(id);
142
+ out.sessions++;
143
+ const facets = readOne(dir, 'facets', id);
144
+ const meta = readOne(dir, 'session-meta', id);
145
+ if (facets.state === 'skipped') out.skipped++;
146
+ if (meta.state === 'skipped') out.skipped++;
147
+ if (facets.state !== 'ok') continue;
148
+ out.matched++;
149
+ const f = facets.value;
150
+ const frictions = frictionsOf(f.friction_counts);
151
+ if (!frictions.length) continue;
152
+ const minutes = meta.state === 'ok' && Number.isFinite(meta.value.duration_minutes) && meta.value.duration_minutes >= 0 ? Math.round(meta.value.duration_minutes) : null;
153
+ out.items.push({ session: s.key, frictions, detail: textOf(f.friction_detail), summary: textOf(f.brief_summary), outcome: textOf(f.outcome), minutes });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * The absolute path of `name` on the PATH, or null. Relative PATH entries are passed over, so a
160
+ * file in the current folder is never run. On Windows it tries each PATHEXT extension.
161
+ */
162
+ export function findOnPath(name, env = process.env, platform = process.platform) {
163
+ const get = (k) => {
164
+ const hit = Object.keys(env ?? {}).find((x) => x.toUpperCase() === k);
165
+ return hit ? env[hit] : undefined;
166
+ };
167
+ const win = platform === 'win32';
168
+ const dirs = String(get('PATH') ?? '').split(win ? ';' : ':').filter((d) => d && isAbsolute(d));
169
+ const exts = win ? String(get('PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').filter((e) => /^\.[A-Za-z0-9]+$/.test(e)) : [''];
170
+ for (const d of dirs) {
171
+ for (const ext of exts) {
172
+ const file = join(d, `${name}${ext}`);
173
+ try {
174
+ if (!statSync(file).isFile()) continue;
175
+ if (!win) accessSync(file, constants.X_OK);
176
+ return file;
177
+ } catch {
178
+ /* not here */
179
+ }
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /** Start `file -p /insights` with claudeEnv. A .cmd or .bat file runs through cmd.exe, quoted, with fixed arguments. */
186
+ function startClaude(file, { env, cwd, platform, spawnFn }) {
187
+ const opts = { cwd, env: claudeEnv(env), stdio: 'ignore', windowsHide: true };
188
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(file)) {
189
+ return spawnFn(opts.env.ComSpec || opts.env.COMSPEC || systemProgram('cmd.exe', env), ['/d', '/s', '/c', `""${file}" -p /insights"`], { ...opts, windowsVerbatimArguments: true });
190
+ }
191
+ return spawnFn(file, ['-p', '/insights'], { ...opts, detached: platform !== 'win32' });
192
+ }
193
+
194
+ /** Stop a run and everything it started. On Windows that's taskkill, from the system folder. */
195
+ export function stopTree(child, { platform, env, spawnFn }) {
196
+ if (!child?.pid) return;
197
+ try {
198
+ if (platform === 'win32') {
199
+ spawnFn(systemProgram('taskkill.exe', env), ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill());
200
+ } else process.kill(-child.pid, 'SIGKILL');
201
+ } catch {
202
+ try {
203
+ child.kill('SIGKILL');
204
+ } catch {
205
+ /* already gone */
206
+ }
207
+ }
208
+ }
209
+
210
+ const json = (status, body) => ({ status, body });
211
+
212
+ /**
213
+ * createInsights({ dir, on, demo, persist, env, platform, cwd, timeoutMs, now, spawnFn })
214
+ * -> { info, read, toggle, run, stop, setOn, isOn }
215
+ * `persist(on)` remembers the toggle in the config; it answers { remembered: true } or
216
+ * { remembered: false, note } and throws with a plain message when the write fails.
217
+ */
218
+ export function createInsights({ dir = null, on = false, demo = false, persist = async () => ({ remembered: false }), env = process.env, platform = process.platform, cwd = process.cwd(), timeoutMs = RUN_TIMEOUT_MS, now = () => Date.now(), spawnFn = spawn } = {}) {
219
+ let isOn = on === true;
220
+ let remembered = null; // null until a toggle answers; then whether it reached the config
221
+ let child = null;
222
+ let timer = null;
223
+ const run = { state: 'idle', startedAt: null, endedAt: null, code: null, runs: 0 };
224
+
225
+ function runState() {
226
+ const end = run.endedAt ?? now();
227
+ return { state: run.state, runs: run.runs, elapsedMs: run.startedAt == null ? null : end - run.startedAt, code: run.code, timeoutMs };
228
+ }
229
+
230
+ function info() {
231
+ return { on: isOn, remembered, demo, hasData: hasInsightsData(dir), claude: demo ? null : !!findOnPath('claude', env, platform), run: runState() };
232
+ }
233
+
234
+ function finish(state, code = null) {
235
+ if (run.state !== 'running') return;
236
+ clearTimeout(timer);
237
+ timer = null;
238
+ run.state = state;
239
+ run.code = code;
240
+ run.endedAt = now();
241
+ child = null;
242
+ }
243
+
244
+ return {
245
+ info,
246
+ isOn: () => isOn,
247
+ setOn(v) {
248
+ isOn = v === true;
249
+ },
250
+ /** The window's /insights, or null while the toggle is off. */
251
+ read(sessions) {
252
+ return isOn ? readInsights({ dir, sessions }) : null;
253
+ },
254
+ /** POST body { "on": true | false }. */
255
+ async toggle(body) {
256
+ let raw;
257
+ try {
258
+ raw = JSON.parse(String(body));
259
+ } catch {
260
+ raw = null;
261
+ }
262
+ if (!isObject(raw) || typeof raw.on !== 'boolean' || Object.keys(raw).length !== 1) return json(400, { error: 'bad-body', message: 'Send { "on": true } or { "on": false }.' });
263
+ let saved;
264
+ try {
265
+ saved = await persist(raw.on);
266
+ } catch (err) {
267
+ return json(500, { error: 'not-saved', message: `The setting couldn't be saved, so it wasn't changed (${String(err?.message ?? err).split('\n')[0]}).` });
268
+ }
269
+ isOn = raw.on;
270
+ remembered = saved?.remembered === true;
271
+ return json(200, { ...info(), note: saved?.note ?? null });
272
+ },
273
+ /** POST: start `claude -p /insights`, unless the toggle is off, it's the demo, one is running, or claude isn't found. */
274
+ async run() {
275
+ // The page shows the button only while the toggle is on; the server holds to that too.
276
+ if (!isOn) return json(409, { error: 'off', message: "Include /insights is off, so /insights didn't run. Turn it on first.", ...info() });
277
+ if (demo) return json(409, { error: 'demo', message: "Not in the demo: /insights reads your own sessions, not the made-up ones.", ...info() });
278
+ if (run.state === 'running') return json(409, { error: 'running', message: '/insights is already running.', ...info() });
279
+ const file = findOnPath('claude', env, platform);
280
+ if (!file) return json(409, { error: 'no-claude', message: "claude isn't on your PATH.", ...info() });
281
+ if (/["%]/.test(file)) return json(409, { error: 'no-claude', message: "claude's path has a character this page won't pass to a shell.", ...info() });
282
+ let c;
283
+ try {
284
+ c = startClaude(file, { env, cwd, platform, spawnFn });
285
+ } catch (err) {
286
+ return json(500, { error: 'not-started', message: `claude couldn't be started (${String(err?.code ?? err?.message ?? err)}).`, ...info() });
287
+ }
288
+ child = c;
289
+ Object.assign(run, { state: 'running', startedAt: now(), endedAt: null, code: null, runs: run.runs + 1 });
290
+ // An earlier run stopped for time can still exit later; only this run's own end counts.
291
+ c.once('error', () => child === c && finish('failed'));
292
+ c.once('exit', (code) => child === c && finish(code === 0 ? 'done' : 'failed', Number.isInteger(code) ? code : null));
293
+ timer = setTimeout(() => {
294
+ const was = child;
295
+ finish('timeout');
296
+ stopTree(was, { platform, env, spawnFn });
297
+ }, timeoutMs);
298
+ timer.unref?.();
299
+ return json(200, info());
300
+ },
301
+ /** Stop a run in progress (honestweek view is stopping). */
302
+ stop() {
303
+ if (run.state !== 'running') return;
304
+ const was = child;
305
+ finish('stopped');
306
+ stopTree(was, { platform, env, spawnFn });
307
+ },
308
+ };
309
+ }