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
package/lib/build.mjs ADDED
@@ -0,0 +1,680 @@
1
+ // lib/build.mjs — the `build` subcommand: verify-or-abort, redact, emit.
2
+ //
3
+ // build is where "honest by construction" is enforced. It reads the distilled
4
+ // items, re-derives EVERY git-checkable claim against the user's real commits,
5
+ // and ABORTS (exit 2) writing nothing if any cited commit is unresolved or not
6
+ // authored by the configured identity. With the opt-in voice-fence enabled
7
+ // (config.voice.denyMeta), it also aborts if authored prose narrates its own
8
+ // withholding or announces the page's honesty (the prose analogue of the numeric
9
+ // fact-fence). Only after the verify + voice passes does it assemble, redact, and
10
+ // hand the model to the configured emitter. It composes already-specified modules
11
+ // (git verify, voice-fence, redactor, badges, emitter) rather than reimplementing them.
12
+ //
13
+ // Zero runtime dependencies: Node built-ins + system git only.
14
+
15
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
16
+ import { basename, dirname, join, resolve } from 'node:path';
17
+
18
+ import { loadConfig } from './config.mjs';
19
+ import { citedShas, itemRepoLabel, verifyItems, repoMetricsInWindow, commitMessage } from './git.mjs';
20
+ import { loadReaderProfile, ReaderProfileError, READER_FILE } from './reader.mjs';
21
+ import { renderNote } from './emit/client.mjs';
22
+ import { atomicWriteText } from './atomic-json.mjs';
23
+ import { checkVoice } from './voice-fence.mjs';
24
+ import { createRedactor } from './redact.mjs';
25
+ import { statusForTag, STATUSES } from './badges.mjs';
26
+ import { resolveWeek, localDateInTimezone } from './resolve-week.mjs';
27
+ import {
28
+ emit, prepareEmission, prepareSiteEmission, writePreparedEmission,
29
+ } from './emit/index.mjs';
30
+ import { augmentSiteModel, deriveChart } from './site/derive.mjs';
31
+ import { deriveSessions } from './site/sessions.mjs';
32
+ import { resolveProjectsRoot } from './claude-adapter.mjs';
33
+ import { buildPageModel } from './emit/page.mjs';
34
+ import { buildClientModel, deriveClientStats, ClientReportError } from './client.mjs';
35
+ import { buildGoalsModel, render as renderGoals } from './emit/goals-page.mjs';
36
+ import {
37
+ loadRegistry,
38
+ loadChangelog,
39
+ validateObjectives,
40
+ validateChangelog,
41
+ aggregateGoals,
42
+ buildReportsFromSnapshots,
43
+ } from './goals.mjs';
44
+ import { writeArchive } from './archive.mjs';
45
+ import { loadValidatedPromptLane } from './prompt-lane.mjs';
46
+ import { withPromptLock } from './prompt-lock.mjs';
47
+ import { assertNoDigestPending } from './digest-store.mjs';
48
+ import { assertNoDigestGroupCollision, isReservedDigestItem } from './digest-schema.mjs';
49
+ import {
50
+ CARRY_GITIGNORE, deriveNextCarry, hashBytes, lifecycleTransactionNeeded, makeCarryPending, readCarry,
51
+ removeCarryPending, writeCarry, writeCarryPending,
52
+ } from './digest-carry.mjs';
53
+ import { recoverConfiguredCarry } from './carry-recovery.mjs';
54
+ import { ensureGitignore } from './init.mjs';
55
+
56
+ const CONFIG_FILE = 'honestweek.config.json';
57
+ const ITEMS_FILE = 'honestweek.items.json';
58
+ // Opt-in standalone goals: when this registry is present, `page` mode emits a
59
+ // second page (goals.html) beside report.html. Absent -> single-page (unchanged).
60
+ const OBJECTIVES_FILE = 'honestweek.objectives.json';
61
+ const GOAL_CHANGELOG_FILE = 'honestweek.goal-changelog.json';
62
+
63
+ /** Read honestweek's archived page-model snapshots (lib/archive.mjs writes one
64
+ * `<weekStart>.json` per week as `{ week, mode, report }`). Tolerant: a missing
65
+ * dir or a corrupt snapshot is skipped, never aborts the goals page. */
66
+ function readArchivedSnapshots(cwd, archiveDir) {
67
+ const base = join(cwd, archiveDir);
68
+ if (!existsSync(base)) return [];
69
+ let files;
70
+ try {
71
+ files = readdirSync(base);
72
+ } catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const f of files) {
77
+ if (!f.endsWith('.json') || f === 'index.json') continue;
78
+ try {
79
+ const snap = JSON.parse(readFileSync(join(base, f), 'utf8'));
80
+ if (snap && snap.week && snap.report) out.push(snap);
81
+ } catch {
82
+ /* skip a corrupt snapshot; the goals page is a bonus, never a build blocker */
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * emitGoalsPage(...) -> { path, goals, activeGoals, entries, bytes }
90
+ *
91
+ * Aggregate the current week (the freshly-built, redacted page model) plus any
92
+ * archived weeks into the goal lens, then write goals.html BESIDE report.html.
93
+ * Every number is honestweek's own aggregation; every string is escaped by the
94
+ * emitter. Pure local write — like report.html, it publishes nothing.
95
+ */
96
+ function emitGoalsPage({ cwd, config, registry, changelog, week, currentModel, reportPath, nowISO }) {
97
+ // Each archived week is redacted again with the current config as it's read, so a word
98
+ // added to the config after that week was archived doesn't show on the goals page.
99
+ const rescrub = createRedactor(config);
100
+ const archived = readArchivedSnapshots(cwd, config.output.archiveDir).map((snap) => ({ ...snap, report: rescrub.deepRedact(snap.report) }));
101
+ const reports = buildReportsFromSnapshots({ currentWeek: week, currentModel, archived });
102
+ // The report keeps its configured filename; the latest week's item rows and the
103
+ // goals page's cross-links deep-link into THAT name (goals.html is the fixed sibling).
104
+ // A plain file name is linked as it is; any other is linked as a relative, URL-encoded
105
+ // path, so it can never read as a web address or a script.
106
+ const reportName = basename(reportPath);
107
+ const reportHref = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(reportName) ? reportName : `./${encodeURIComponent(reportName)}`;
108
+ const agg = aggregateGoals(reports, registry, changelog, { reportHref });
109
+ const goalsModel = buildGoalsModel({ agg, registry, generatedAt: nowISO, generator: 'honestweek (page mode)', reportHref });
110
+ const html = renderGoals(goalsModel);
111
+ const goalsPath = join(dirname(reportPath), 'goals.html');
112
+ writeFileSync(goalsPath, html);
113
+ return {
114
+ path: goalsPath,
115
+ goals: agg.totals.goals,
116
+ activeGoals: agg.totals.activeGoals,
117
+ entries: agg.totals.entries,
118
+ weeks: agg.totals.weeksTracked,
119
+ bytes: Buffer.byteLength(html, 'utf8'),
120
+ };
121
+ }
122
+
123
+ function defaultIo() {
124
+ return {
125
+ out: (s) => process.stdout.write(s),
126
+ err: (s) => process.stderr.write(s),
127
+ exit: (code) => process.exit(code),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Loud diagnostic when a session-deriving build (site/page) found ZERO session logs at the resolved root.
133
+ * Turns the silent degrade (off-machine / wrong CLAUDE_CONFIG_DIR -> every session-derived number is 0, with
134
+ * no signal) into an obvious one. Diagnostic only: never changes the artifact, the numbers, or the exit code.
135
+ *
136
+ * Root-resolution invariant: the message re-resolves the root via resolveProjectsRoot() rather than threading
137
+ * it out of deriveSessions. This names the SAME root that was scanned because neither the site nor the page
138
+ * path passes a `projectsRoot` override, so deriveSessions resolves it via the same pure, env-based
139
+ * resolveProjectsRoot(). If a future change threads a custom projectsRoot, pass that root here instead.
140
+ */
141
+ function warnIfNoSessionLogs(sessions, io) {
142
+ if (!sessions || sessions.filesFound !== 0) return;
143
+ io.err(
144
+ `honestweek: no Claude session logs found under ${resolveProjectsRoot()} — session-derived numbers ` +
145
+ `(interactive sessions, session-active days) will be 0 for every project. If your logs live elsewhere, ` +
146
+ `set CLAUDE_CONFIG_DIR. (Expected when building off your local machine.)\n`
147
+ );
148
+ }
149
+
150
+ /** The primary cited commit SHA for an item, from any supported provenance. */
151
+ function pickPrimarySha(item) {
152
+ return (
153
+ item.primaryCommit ||
154
+ item.receipt?.primaryCommit ||
155
+ item.commit ||
156
+ (Array.isArray(item.commits) ? item.commits[0] : undefined) ||
157
+ (Array.isArray(item.candidateCommits)
158
+ ? typeof item.candidateCommits[0] === 'string'
159
+ ? item.candidateCommits[0]
160
+ : item.candidateCommits[0]?.sha
161
+ : undefined) ||
162
+ null
163
+ );
164
+ }
165
+
166
+ /** The public status badge for an item (explicit status wins; else map its tag). */
167
+ function statusFor(item) {
168
+ if (STATUSES.includes(item.status)) return item.status;
169
+ return statusForTag(item.tag ?? item.status);
170
+ }
171
+
172
+ /** Build the receipt for a model item — commit data is ALWAYS the git-derived
173
+ * value, never what the items file carried. */
174
+ function resolveReceipt(item, verifiedIndex) {
175
+ const sha = pickPrimarySha(item);
176
+ if (sha && verifiedIndex.has(sha)) {
177
+ const v = verifiedIndex.get(sha);
178
+ return { kind: 'commit', sha, shortSha: v.shortSha, dateISO: v.dateISO };
179
+ }
180
+ const r = item.receipt;
181
+ if (r && typeof r === 'object' && (r.sessionId || r.ref || r.turn)) {
182
+ return { kind: 'session', sessionId: r.sessionId, ref: r.ref ?? r.sessionId ?? item.id };
183
+ }
184
+ if (typeof r === 'string' && r.trim()) return r;
185
+ const sid = item.sessionId ?? item.session ?? item.id;
186
+ if (sid) return { kind: 'session', sessionId: sid, ref: sid };
187
+ return undefined; // the emitter will reject a receipt-less item
188
+ }
189
+
190
+ /**
191
+ * assembleReportModel(items, config, verifiedIndex, week) -> reportModel
192
+ * Groups items by repo (featured groups first, then reference, then others);
193
+ * repo-less items go in a top-level list. Pure + testable.
194
+ */
195
+ export function assembleReportModel(items, config, verifiedIndex, week, metricsByLabel = new Map()) {
196
+ const roleByLabel = new Map((config.repos ?? []).map((r) => [r.label, r.role]));
197
+ const groupsByLabel = new Map();
198
+ const looseItems = [];
199
+
200
+ for (const item of items) {
201
+ const modelItem = {
202
+ status: statusFor(item),
203
+ text: item.text ?? item.summary ?? '',
204
+ repo: item.repo ?? null,
205
+ receipt: resolveReceipt(item, verifiedIndex),
206
+ };
207
+ const label = item.repo;
208
+ if (label && roleByLabel.has(label)) {
209
+ if (!groupsByLabel.has(label)) groupsByLabel.set(label, { label, role: roleByLabel.get(label), items: [] });
210
+ groupsByLabel.get(label).items.push(modelItem);
211
+ } else {
212
+ looseItems.push(modelItem);
213
+ }
214
+ }
215
+
216
+ const roleRank = { featured: 0, reference: 1, display: 2 };
217
+ const groups = [...groupsByLabel.values()].sort(
218
+ (a, b) => (roleRank[a.role] ?? 9) - (roleRank[b.role] ?? 9) || a.label.localeCompare(b.label)
219
+ );
220
+
221
+ // Attach git-derived metrics to each non-display group. `entries` is always the
222
+ // render-count; commits/activeDays come from metricsByLabel when the repo was
223
+ // readable. DISPLAY repos are NEVER git-read, so they carry no metrics at all.
224
+ for (const g of groups) {
225
+ if (g.role === 'display') continue;
226
+ const git = metricsByLabel.get(g.label) || null;
227
+ g.metrics = { entries: g.items.length, ...(git || {}) };
228
+ }
229
+
230
+ return { week, groups, items: looseItems };
231
+ }
232
+
233
+ const WEEK_KEY_RE = /^\d{4}-\d{2}-\d{2}$/;
234
+
235
+ function normalizeWeek(week, now, config) {
236
+ if (week && (week.start || week.weekStart)) {
237
+ const norm = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10));
238
+ return { start: norm(week.start ?? week.weekStart), end: norm(week.end ?? week.weekEnd) };
239
+ }
240
+ // Fall back to the last completed week, tz-correct against the real now.
241
+ const tz = config.week?.timezone || 'UTC';
242
+ const today = localDateInTimezone(now, tz);
243
+ const { weekStart, weekEnd } = resolveWeek({ today });
244
+ return { start: weekStart.toISOString().slice(0, 10), end: weekEnd.toISOString().slice(0, 10) };
245
+ }
246
+
247
+ /**
248
+ * runBuild({ cwd, argv, now, io }) -> exit code (0 success).
249
+ * On a verification failure it calls io.exit(2) after a clear message and writes
250
+ * nothing. io.exit defaults to process.exit; tests inject a throwing exit.
251
+ */
252
+ export async function runBuild({ cwd = process.cwd(), argv = [], now = new Date(), io = defaultIo(), promptLockHeld = false, transactionFs = {} } = {}) {
253
+ if (!promptLockHeld) {
254
+ let code;
255
+ try {
256
+ assertNoDigestPending(cwd);
257
+ code = await withPromptLock(
258
+ cwd,
259
+ () => runBuild({ cwd, argv, now, io: { out: io.out, err: io.err, exit: (value) => value }, promptLockHeld: true, transactionFs }),
260
+ { ensureIgnored: false },
261
+ );
262
+ } catch (err) {
263
+ io.err(`build: ABORTED: ${err.message}\nNo output was written.\n`);
264
+ code = 2;
265
+ }
266
+ return code === 0 ? 0 : (io.exit(code) ?? code);
267
+ }
268
+ const configPath = join(cwd, CONFIG_FILE);
269
+ const itemsPath = join(cwd, ITEMS_FILE);
270
+
271
+ let config;
272
+ try {
273
+ config = loadConfig(configPath);
274
+ } catch (err) {
275
+ io.err(`build: ${err.message}\n`);
276
+ return io.exit(1) ?? 1;
277
+ }
278
+
279
+ try {
280
+ assertNoDigestPending(cwd);
281
+ await recoverConfiguredCarry({
282
+ cwd, config, hasGoals: existsSync(join(cwd, OBJECTIVES_FILE)), fs: transactionFs.recovery,
283
+ });
284
+ } catch (err) {
285
+ io.err(`build: ABORTED: ${err.message}\nNo output was written.\n`);
286
+ return io.exit(2) ?? 2;
287
+ }
288
+
289
+ if (!existsSync(itemsPath)) {
290
+ io.err(`build: ${ITEMS_FILE} not found in ${cwd}. Run discover and distil first.\n`);
291
+ return io.exit(1) ?? 1;
292
+ }
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(readFileSync(itemsPath, 'utf8'));
296
+ } catch (err) {
297
+ io.err(`build: ${ITEMS_FILE} is not valid JSON (${err.message}).\n`);
298
+ return io.exit(1) ?? 1;
299
+ }
300
+
301
+ let items = Array.isArray(parsed) ? parsed : Array.isArray(parsed.items) ? parsed.items : [];
302
+ if (items.some(isReservedDigestItem)) {
303
+ io.err(`build: ABORTED: ${ITEMS_FILE} cannot contain reserved digest-lane item fields.\nNo output was written.\n`);
304
+ return io.exit(2) ?? 2;
305
+ }
306
+ // A client report covers any period the items file names (`period`, or `week`);
307
+ // the weekly modes keep defaulting to the last completed week.
308
+ const namedPeriod = Array.isArray(parsed) ? null : parsed.period ?? parsed.week;
309
+ if (config.output.mode === 'client' && !(namedPeriod?.start && namedPeriod?.end)) {
310
+ io.err(`build: mode "client" needs a "period": { "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" } in ${ITEMS_FILE}; it never guesses the period.
311
+ `);
312
+ return io.exit(1) ?? 1;
313
+ }
314
+ const week = normalizeWeek(Array.isArray(parsed) ? null : parsed.period ?? parsed.week, now, config);
315
+ // The week's dates name the archive's snapshot file, so each must be a plain date.
316
+ if (!WEEK_KEY_RE.test(week.start) || !WEEK_KEY_RE.test(week.end)) {
317
+ io.err(`build: ABORTED: the week in ${ITEMS_FILE} must give its start and end as YYYY-MM-DD dates.\nNo output was written.\n`);
318
+ return io.exit(2) ?? 2;
319
+ }
320
+ // Curated passthrough for site mode: authored editorial the items file carries
321
+ // (top-level content + project metadata). Redacted with everything else; the
322
+ // site adapter/transform maps it. Absent for non-site modes.
323
+ const content = Array.isArray(parsed) ? null : parsed.content ?? null;
324
+ const projects = Array.isArray(parsed) ? null : parsed.projects ?? null;
325
+
326
+ // Prompt-lane preflight is deliberately before every Git read and output
327
+ // write. It re-resolves local transcript receipts and reconstructs the exact
328
+ // canonical lane, so stale controls, source edits, or sidecar tampering abort.
329
+ let promptLaneState;
330
+ let priorCarry = { value: null, hash: null };
331
+ let lifecycleActive = false;
332
+ try {
333
+ promptLaneState = await loadValidatedPromptLane({
334
+ cwd, config, week, now,
335
+ hasGoals: existsSync(join(cwd, OBJECTIVES_FILE)),
336
+ });
337
+ priorCarry = readCarry(cwd, config, { optional: true });
338
+ if (priorCarry.value && promptLaneState.lane?.version !== 2) {
339
+ throw new Error('canonical carry requires a prepared balanced digest lane.');
340
+ }
341
+ if (promptLaneState.lane?.version === 2) {
342
+ lifecycleActive = lifecycleTransactionNeeded({
343
+ review: promptLaneState.review, lane: promptLaneState.lane, priorCarry: priorCarry.value,
344
+ });
345
+ }
346
+ assertNoDigestGroupCollision(items, promptLaneState.lane, projects, config.repos);
347
+ items = [...items, ...promptLaneState.items];
348
+ } catch (err) {
349
+ io.err(`build: ABORTED: ${err.message}\nNo output was written.\n`);
350
+ return io.exit(2) ?? 2;
351
+ }
352
+
353
+ // --- verify-or-abort (over ALL cited commits, before any assembly/emit) ---
354
+ const { ok, problems, verified } = verifyItems(items, config);
355
+ if (!ok) {
356
+ const p = problems[0];
357
+ io.err(
358
+ `build: ABORTED — item ${p.item} (repo ${JSON.stringify(p.repo)}) cites commit ${p.sha}: ${p.reason}\n` +
359
+ `No output was written.\n`
360
+ );
361
+ return io.exit(2) ?? 2;
362
+ }
363
+
364
+ // --- landed gate: `shipped` requires landed commits -----------------------
365
+ // A `shipped` badge claims the work is on the default branch. verifyItems
366
+ // re-derived that offline (local refs, never a fetch) as `landed` on each
367
+ // verified commit. A real-but-unlanded commit keeps its receipt and
368
+ // downgrades its item to the honest `in progress` — loudly, never silently.
369
+ // When the repo has NO determinable default branch (`landed: null`) the
370
+ // shipped claim is unverifiable, which aborts exactly like any other
371
+ // unverifiable claim. Runs BEFORE assembly so every mode (post, changelog,
372
+ // digest, report, page, site) sees the honest status.
373
+ // Keyed by REPO **and** sha, never by sha alone: the same commit object can sit
374
+ // in two configured repos (a fork and its upstream, a stale second clone) and be
375
+ // landed in one but not the other. Under a sha-only key the last repo verified
376
+ // overwrites the first one's verdict, which is exactly how an unlanded item
377
+ // silently keeps `shipped`. The label is resolved with the same helper
378
+ // verifyItems recorded it under, so a lookup cannot miss by spelling.
379
+ const landedKey = (repoLabel, sha) => `${JSON.stringify(repoLabel ?? null)}\u0000${sha}`;
380
+ const landedByRepoSha = new Map(verified.map((v) => [landedKey(v.repoLabel, v.sha), v.landed]));
381
+ for (let i = 0; i < items.length; i++) {
382
+ const item = items[i];
383
+ if (statusFor(item) !== 'shipped') continue;
384
+ const shas = citedShas(item);
385
+ if (shas.length === 0) continue;
386
+ const ref = item.id ?? `item[${i}]`;
387
+ const label = itemRepoLabel(item);
388
+ // `true` is the ONLY verdict that sustains `shipped`. null (no determinable
389
+ // default branch) and a missing entry (unreachable after verify-or-abort,
390
+ // guarded so a future refactor fails closed) are both unverifiable.
391
+ const verdictFor = (sha) => landedByRepoSha.get(landedKey(label, sha));
392
+ const unknowable = shas.find((sha) => {
393
+ const verdict = verdictFor(sha);
394
+ return verdict === null || verdict === undefined;
395
+ });
396
+ if (unknowable !== undefined) {
397
+ io.err(
398
+ `build: ABORTED — item ${ref} claims "shipped" citing commit ${unknowable}, but repo ` +
399
+ `${JSON.stringify(label)} has no determinable default branch (need origin/HEAD, a ` +
400
+ `main/master branch, or a single local branch), so whether that commit landed cannot be ` +
401
+ `verified offline.\nNo output was written.\n`
402
+ );
403
+ return io.exit(2) ?? 2;
404
+ }
405
+ const unlanded = shas.filter((sha) => verdictFor(sha) === false);
406
+ if (unlanded.length === 0) continue;
407
+ items[i] = { ...item, status: 'in progress' };
408
+ io.err(
409
+ `build: item ${ref} cites commit ${unlanded[0].slice(0, 7)} that has not landed on the default ` +
410
+ `branch of repo ${JSON.stringify(label)} — "shipped" downgraded to "in progress" ` +
411
+ `(real work, not landed yet).\n`
412
+ );
413
+ }
414
+
415
+ // --- git-derived per-project metrics (numbers re-derived, never authored) ---
416
+ // Featured/reference repos only; DISPLAY repos are never git-read. An unreadable
417
+ // repo yields no metric (repoMetricsInWindow returns null) — never a fake 0.
418
+ const sinceISO = `${week.start}T00:00:00.000Z`;
419
+ const untilISO = `${week.end}T23:59:59.999Z`;
420
+ const metricsByLabel = new Map();
421
+ for (const repo of config.repos ?? []) {
422
+ if (repo.role === 'display') continue;
423
+ const m = repoMetricsInWindow(repo.resolvedPath ?? repo.path, config.identity.authorEmails, sinceISO, untilISO);
424
+ if (m) metricsByLabel.set(repo.label, m);
425
+ }
426
+
427
+ // --- assemble -> (augment for site) -> redact -> emit ---
428
+ const verifiedIndex = new Map(verified.map((v) => [v.sha, v]));
429
+ const model = assembleReportModel(items, config, verifiedIndex, week, metricsByLabel);
430
+ const redactor = createRedactor(config);
431
+
432
+ // --- voice-fence (opt-in): the authored-prose honesty lint, after assembly and
433
+ // before emit. Like verify-or-abort and the numeric fact-fence, a violation
434
+ // ABORTS (exit 2, writes nothing). OFF by default (config.voice.denyMeta), so an
435
+ // existing consumer is unchanged. It scans the AUTHORED source fields every mode
436
+ // renders (item title/summary/text + the curated content/projects editorial),
437
+ // never evidence snippets/receipts. (A site TRANSFORM that surfaces other fields
438
+ // owns its own curated strings — trusted passthrough; see lib/site/transform.mjs.)
439
+ // Done OUTSIDE the emit try/catch below (like verify-or-abort) so the exit-2 abort
440
+ // is never re-mapped to a generic exit 1.
441
+ if (config.voice?.denyMeta) {
442
+ const violations = checkVoice(
443
+ { items, content, projects },
444
+ { denyPhrases: config.voice.denyPhrases, allowPhrases: config.voice.allowPhrases }
445
+ );
446
+ if (violations.length) {
447
+ io.err(
448
+ `build: ABORTED — voice: ${violations.length} self-undermining phrase(s) in authored prose:\n` +
449
+ violations.map((v) => ` - ${v.path}: ${JSON.stringify(v.phrase)} [${v.rule}]`).join('\n') +
450
+ `\nThese narrate withholding or announce the page's own honesty — show it through badges/receipts, ` +
451
+ `don't say it. Reword them (or carve out a false positive with voice.allowPhrases). No output was written.\n`
452
+ );
453
+ return io.exit(2) ?? 2;
454
+ }
455
+ }
456
+
457
+ // Opt-in MULTI-PAGE goals (page mode only): detect + validate honestweek.objectives.json
458
+ // BEFORE any emit, so an invalid/leaky registry aborts cleanly (exit 2, write nothing).
459
+ // Done OUT of the emit try/catch below, exactly like verify-or-abort, so the abort's
460
+ // io.exit is never re-mapped to a generic exit 1. Absent registry -> single-page.
461
+ let goalsRegistry = null;
462
+ let goalsChangelog = null;
463
+ if (config.output.mode === 'page' && existsSync(join(cwd, OBJECTIVES_FILE))) {
464
+ try {
465
+ goalsRegistry = loadRegistry(join(cwd, OBJECTIVES_FILE));
466
+ } catch (err) {
467
+ io.err(`build: ABORTED — cannot read ${OBJECTIVES_FILE} (${err.message}).\nNo output was written.\n`);
468
+ return io.exit(2) ?? 2;
469
+ }
470
+ try {
471
+ goalsChangelog = loadChangelog(join(cwd, GOAL_CHANGELOG_FILE));
472
+ } catch (err) {
473
+ io.err(`build: ABORTED — cannot read ${GOAL_CHANGELOG_FILE} (${err.message}).\nNo output was written.\n`);
474
+ return io.exit(2) ?? 2;
475
+ }
476
+ const projectLabels = new Set((config.repos ?? []).map((r) => r.label));
477
+ const valRedactor = createRedactor(config);
478
+ const objV = validateObjectives({ registry: goalsRegistry, projectLabels, redactor: valRedactor });
479
+ const clV = validateChangelog({ changelog: goalsChangelog, registry: goalsRegistry, redactor: valRedactor });
480
+ for (const w of [...objV.warnings, ...clV.warnings]) io.err(`build: goal-registry warning: ${w}\n`);
481
+ const errs = [...objV.errors, ...clV.errors];
482
+ if (errs.length) {
483
+ io.err(
484
+ `build: ABORTED — ${OBJECTIVES_FILE} / ${GOAL_CHANGELOG_FILE} invalid:\n` +
485
+ errs.map((e) => ` - ${e}`).join('\n') +
486
+ `\nNo output was written.\n`
487
+ );
488
+ return io.exit(2) ?? 2;
489
+ }
490
+ }
491
+
492
+ let result;
493
+ let preparedPrimary = null;
494
+ let goalsResult = null; // the optional second page (goals.html), when a registry is present
495
+ let clientNote = null; // the client report's short note (profile format.note), written beside it
496
+ let noteResult = null;
497
+ let redactedForArchive = null; // the honestweek /log snapshot source (markdown modes only)
498
+ try {
499
+ if (config.output.mode === 'site') {
500
+ // Augment with the git/session-derived bundle BEFORE redaction, so authored
501
+ // content + the chart's per-day item titles are scrubbed by one pass.
502
+ const bundle = augmentSiteModel(model, { config, items, verified, verifiedIndex, week, now, content, projects });
503
+ warnIfNoSessionLogs(bundle.sessions, io); // loud signal if the session root yielded nothing (off-machine / wrong CLAUDE_CONFIG_DIR)
504
+ let toEmit = bundle;
505
+ if (config.output.redact !== false) {
506
+ toEmit = redactor.deepRedact(bundle);
507
+ // provenance.redactions is the true scrub count for this write (filled
508
+ // here, post-redaction). A derived number, so the fact-fence accepts it.
509
+ if (toEmit.provenance) toEmit.provenance.redactions = redactor.count;
510
+ }
511
+ // When output.redact is false the target's transform owns redaction (for
512
+ // placeholder parity with its own redactor) + sets provenance.redactions.
513
+ preparedPrimary = await prepareSiteEmission(toEmit, config, { cwd }, { now: now.toISOString(), nowDate: now });
514
+ } else if (config.output.mode === 'client') {
515
+ // A report for one client over any period: git-derived totals and PR list for the
516
+ // period, the verified items grouped by area, then redact + one self-contained HTML
517
+ // file. A cited commit outside the period aborts (ClientReportError -> exit 2).
518
+ // The reader profile (lib/reader.mjs) chooses and orders what this reader sees;
519
+ // it never edits an item. Absent a honestweek.reader.json, default + client.
520
+ const profile = loadReaderProfile(cwd);
521
+ if (profile.unconfirmed) {
522
+ io.err(`build: note: ${READER_FILE} is made only of guesses, so this view of the reader is unconfirmed. Replace a guess with their words or your notes when you can.\n`);
523
+ }
524
+ const repoPathByLabel = new Map((config.repos ?? []).filter((r) => r.role !== 'display').map((r) => [r.label, r.resolvedPath ?? r.path]));
525
+ const messageFor = (repo, sha) => (repoPathByLabel.has(repo) ? commitMessage(repoPathByLabel.get(repo), sha) : null);
526
+ const stats = deriveClientStats({ config, period: week });
527
+ const clientModel = buildClientModel({ items, config, verified, period: week, content, stats, now, profile, messageFor });
528
+ const redactedClient = redactor.deepRedact(clientModel);
529
+ preparedPrimary = prepareEmission(redactedClient, config, { cwd });
530
+ if (profile.format.note) clientNote = { model: redactedClient };
531
+ } else if (config.output.mode === 'page') {
532
+ // Standalone site: derive the commits/day chart (honestweek's own git numbers)
533
+ // and shape the page model from the verified items, then redact + emit one
534
+ // self-contained interactive HTML file. Same verify-or-abort + git-derived
535
+ // numbers as every other mode; curated prose is escaped at render.
536
+ const todayKey = localDateInTimezone(now, config.week?.timezone || 'UTC').toISOString().slice(0, 10);
537
+ const chart = deriveChart({ config, weekStartKey: week.start, weekEndKey: week.end, todayKey });
538
+ // Session-active days for the per-project metrics (so a display/session-only repo shows the
539
+ // days it had interactive sessions, not a blank). `week` is string-shaped in this branch, so
540
+ // reconstruct the Date window deriveSessions expects. deriveChart + deriveSessions span the
541
+ // SAME weekGrid(weekStartKey, todayKey), so sessions.days.length === chart.windowDays.
542
+ const weekStart = new Date(`${week.start}T00:00:00.000Z`);
543
+ const weekEnd = new Date(`${week.end}T23:59:59.999Z`);
544
+ const sessions = deriveSessions({ config, weekStart, weekEnd, now });
545
+ warnIfNoSessionLogs(sessions, io); // loud signal if the session root yielded nothing (off-machine / wrong CLAUDE_CONFIG_DIR)
546
+
547
+ // The registry was already detected + validated (fail-closed) above; a present
548
+ // goalsRegistry means page mode goes MULTI-PAGE (report.html + goals.html).
549
+ const pageModel = buildPageModel({ items, config, verifiedIndex, week, chart, metricsByLabel, sessions, content, hasGoals: !!goalsRegistry, redact: redactor.redact });
550
+ redactedForArchive = redactor.deepRedact(pageModel);
551
+ preparedPrimary = prepareEmission(redactedForArchive, config, { cwd });
552
+ } else {
553
+ redactedForArchive = redactor.deepRedact(model);
554
+ result = emit(redactedForArchive, config, { cwd });
555
+ }
556
+ if (preparedPrimary && lifecycleActive) {
557
+ try {
558
+ for (const entry of CARRY_GITIGNORE) ensureGitignore(cwd, entry);
559
+ } catch (error) {
560
+ const failure = new Error(`carry transaction did not start because private state could not be ignored (${error.message}); no output or carry was changed.`);
561
+ failure.lifecycleTransaction = true;
562
+ throw failure;
563
+ }
564
+ const nextCarry = deriveNextCarry({
565
+ priorCarry: priorCarry.value, priorCarryHash: priorCarry.hash,
566
+ review: promptLaneState.review, lane: promptLaneState.lane, week, config,
567
+ });
568
+ const outputBinding = {
569
+ ...promptLaneState.lane.outputBinding,
570
+ outputPath: resolve(preparedPrimary.path),
571
+ };
572
+ const pending = makeCarryPending({
573
+ week, priorCarryHash: priorCarry.hash, carry: nextCarry,
574
+ targetOutputHash: hashBytes(preparedPrimary.content), outputBinding, config,
575
+ });
576
+ try {
577
+ writeCarryPending(cwd, pending, config, transactionFs.pending);
578
+ } catch (error) {
579
+ const failure = new Error(`carry transaction did not start because the pending write failed (${error.message}); no output or carry was changed.`);
580
+ failure.lifecycleTransaction = true;
581
+ throw failure;
582
+ }
583
+ try {
584
+ result = writePreparedEmission(preparedPrimary, transactionFs.output);
585
+ } catch (error) {
586
+ try { removeCarryPending(cwd, transactionFs.removeAfterOutputFailure); }
587
+ catch (removeError) {
588
+ const failure = new Error(`primary output write failed (${error.message}) and the prepared carry marker remains (${removeError.message}); explicit pending discard is required.`);
589
+ failure.lifecycleTransaction = true;
590
+ throw failure;
591
+ }
592
+ const failure = new Error(`primary output write failed (${error.message}); prior output and carry were preserved.`);
593
+ failure.lifecycleTransaction = true;
594
+ throw failure;
595
+ }
596
+ try {
597
+ writeCarryPending(cwd, { ...pending, phase: 'output-written' }, config, transactionFs.phase);
598
+ writeCarry(cwd, nextCarry, config, transactionFs.carry);
599
+ removeCarryPending(cwd, transactionFs.remove);
600
+ } catch (error) {
601
+ const failure = new Error(`carry transaction stopped after primary output installation (${error.message}); rerun any honestweek command to recover by hash.`);
602
+ failure.lifecycleTransaction = true;
603
+ throw failure;
604
+ }
605
+ } else if (preparedPrimary) {
606
+ result = writePreparedEmission(preparedPrimary);
607
+ }
608
+
609
+ // The client report's short note: the same redacted model, a few lines for the
610
+ // place the reader already reads (a shared document), written beside the report.
611
+ if (clientNote && result?.path) {
612
+ const notePath = result.path.replace(/\.html?$/i, '') + '.note.md';
613
+ const text = renderNote(clientNote.model, { reportFile: basename(result.path) });
614
+ atomicWriteText(notePath, text, undefined, { newMode: null });
615
+ noteResult = { path: notePath, bytes: Buffer.byteLength(text, 'utf8') };
616
+ }
617
+
618
+ // Second page: aggregate the current week + archived weeks into goals.html,
619
+ // written beside report.html (cross-linked). The aggregation reads the same
620
+ // redacted model report.html rendered, so the two pages can never disagree.
621
+ if (goalsRegistry) {
622
+ goalsResult = emitGoalsPage({
623
+ cwd,
624
+ config,
625
+ registry: goalsRegistry,
626
+ changelog: goalsChangelog,
627
+ week,
628
+ currentModel: redactedForArchive,
629
+ reportPath: result.path,
630
+ nowISO: now.toISOString(),
631
+ });
632
+ }
633
+ } catch (err) {
634
+ // A fact-fence (or resolve) abort is a verify-or-abort: exit 2, write nothing.
635
+ if (err && (err.factFence || err instanceof ClientReportError || err instanceof ReaderProfileError)) {
636
+ io.err(`build: ABORTED — ${err.message}\nNo output was written.\n`);
637
+ return io.exit(2) ?? 2;
638
+ }
639
+ if (err && err.lifecycleTransaction) {
640
+ io.err(`build: ABORTED: ${err.message}\n`);
641
+ return io.exit(2) ?? 2;
642
+ }
643
+ io.err(`build: ${err.message}\n`);
644
+ return io.exit(1) ?? 1;
645
+ }
646
+
647
+ // Opt-in local weekly archive — a snapshot + index of past weeks ("/log").
648
+ // Local file writes only; an archive failure warns but never loses the primary
649
+ // output or aborts (the rendered output is the contract; the archive is a bonus).
650
+ let archived = null;
651
+ if (config.output?.archive && redactedForArchive) {
652
+ try {
653
+ archived = writeArchive({
654
+ cwd,
655
+ dir: config.output.archiveDir,
656
+ week,
657
+ mode: config.output.mode,
658
+ model: redactedForArchive,
659
+ nowISO: now.toISOString(),
660
+ });
661
+ } catch (err) {
662
+ io.err(`build: WARNING — archive write failed (${err.message}); the ${result.mode} output was still written.\n`);
663
+ }
664
+ }
665
+
666
+ io.out(
667
+ `build: wrote ${result.path} (${result.mode}, ${result.items} item(s), ${result.bytes} bytes).` +
668
+ (goalsResult
669
+ ? ` Wrote ${goalsResult.path} (${goalsResult.activeGoals}/${goalsResult.goals} goals active, ${goalsResult.entries} entr${goalsResult.entries === 1 ? 'y' : 'ies'}, ${goalsResult.weeks} week(s)).`
670
+ : '') +
671
+ (archived ? ` Archived ${archived.snapshotFile} (${archived.weeks} week(s) indexed).` : '') +
672
+ (noteResult ? ` Wrote ${noteResult.path} (short note, ${noteResult.bytes} bytes).` : '') +
673
+ '\n'
674
+ );
675
+ return 0;
676
+ }
677
+
678
+ export default function run(argv) {
679
+ return runBuild({ argv });
680
+ }