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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +661 -2
- package/SKILL.md +129 -0
- package/bin/honestweek.mjs +240 -0
- package/honestweek.config.example.json +76 -0
- package/lib/archive.mjs +63 -0
- package/lib/atomic-json.mjs +42 -0
- package/lib/badges.mjs +43 -0
- package/lib/bounded-jsonl.mjs +39 -0
- package/lib/build.mjs +680 -0
- package/lib/carry-receipts.mjs +102 -0
- package/lib/carry-recovery.mjs +19 -0
- package/lib/claude-adapter.mjs +602 -0
- package/lib/client.mjs +397 -0
- package/lib/codex-records.mjs +167 -0
- package/lib/config.mjs +511 -0
- package/lib/curation-similarity.mjs +20 -0
- package/lib/demo/content.mjs +262 -0
- package/lib/demo/extra.mjs +1061 -0
- package/lib/demo/repo.mjs +385 -0
- package/lib/demo/week.mjs +2558 -0
- package/lib/digest-carry.mjs +538 -0
- package/lib/digest-curation.mjs +548 -0
- package/lib/digest-evidence.mjs +348 -0
- package/lib/digest-lifecycle.mjs +142 -0
- package/lib/digest-schema.mjs +58 -0
- package/lib/digest-source-bound.mjs +46 -0
- package/lib/digest-store.mjs +485 -0
- package/lib/digest.mjs +437 -0
- package/lib/discover.mjs +193 -0
- package/lib/emit/_shared.mjs +122 -0
- package/lib/emit/changelog.mjs +62 -0
- package/lib/emit/client.mjs +311 -0
- package/lib/emit/digest.mjs +53 -0
- package/lib/emit/goals-page.mjs +416 -0
- package/lib/emit/index.mjs +155 -0
- package/lib/emit/page.mjs +423 -0
- package/lib/emit/post.mjs +22 -0
- package/lib/emit/report.mjs +51 -0
- package/lib/git.mjs +568 -0
- package/lib/goals.mjs +539 -0
- package/lib/handoffs.mjs +151 -0
- package/lib/harvest.mjs +162 -0
- package/lib/history.mjs +110 -0
- package/lib/init.mjs +576 -0
- package/lib/invocation.mjs +102 -0
- package/lib/loopback-host.mjs +16 -0
- package/lib/mine/corpus.mjs +641 -0
- package/lib/mine/detect.mjs +681 -0
- package/lib/mine/draft.mjs +262 -0
- package/lib/mine/ledger.mjs +189 -0
- package/lib/mine/rank.mjs +236 -0
- package/lib/mine.mjs +381 -0
- package/lib/preview.mjs +504 -0
- package/lib/private-words.mjs +31 -0
- package/lib/problems/catalog.json +5884 -0
- package/lib/problems/checks.mjs +1454 -0
- package/lib/problems/classify.mjs +910 -0
- package/lib/problems/context.mjs +448 -0
- package/lib/problems/drafts.mjs +57 -0
- package/lib/problems/fix-tests.mjs +270 -0
- package/lib/problems/index.mjs +332 -0
- package/lib/problems/scope.mjs +401 -0
- package/lib/prompt-adapters.mjs +229 -0
- package/lib/prompt-curation.mjs +71 -0
- package/lib/prompt-identity.mjs +18 -0
- package/lib/prompt-lane.mjs +84 -0
- package/lib/prompt-lock.mjs +23 -0
- package/lib/prompt-privacy.mjs +451 -0
- package/lib/prompt-store.mjs +111 -0
- package/lib/prompts.mjs +100 -0
- package/lib/reader.mjs +189 -0
- package/lib/readers/client.json +9 -0
- package/lib/readers/default.json +5 -0
- package/lib/redact.mjs +577 -0
- package/lib/redaction-patterns.mjs +993 -0
- package/lib/replay/assemble.mjs +523 -0
- package/lib/replay/classify.mjs +464 -0
- package/lib/replay/claude.mjs +937 -0
- package/lib/replay/codex-program.mjs +459 -0
- package/lib/replay/codex.mjs +908 -0
- package/lib/replay/evidence.mjs +78 -0
- package/lib/replay/goals.mjs +287 -0
- package/lib/replay/ids.mjs +54 -0
- package/lib/replay/index.mjs +690 -0
- package/lib/replay/jsonl.mjs +102 -0
- package/lib/replay/launch.mjs +164 -0
- package/lib/replay/lookup.mjs +374 -0
- package/lib/replay/metrics.mjs +51 -0
- package/lib/replay/outcomes.mjs +233 -0
- package/lib/replay/parse-common.mjs +281 -0
- package/lib/replay/sources.mjs +205 -0
- package/lib/replay/timeline.mjs +331 -0
- package/lib/replay/views.mjs +340 -0
- package/lib/repo-identity.mjs +101 -0
- package/lib/resolve-week.mjs +178 -0
- package/lib/site/adapter.mjs +168 -0
- package/lib/site/archive.mjs +48 -0
- package/lib/site/derive.mjs +443 -0
- package/lib/site/detect.mjs +138 -0
- package/lib/site/emit-site.mjs +94 -0
- package/lib/site/fact-fence.mjs +148 -0
- package/lib/site/inspect.mjs +102 -0
- package/lib/site/load-adapter.mjs +30 -0
- package/lib/site/sessions.mjs +274 -0
- package/lib/site/transform.mjs +114 -0
- package/lib/site/values.mjs +153 -0
- package/lib/site/week-grid.mjs +49 -0
- package/lib/validate.mjs +258 -0
- package/lib/view/assets/common.css +789 -0
- package/lib/view/assets/common.js +1007 -0
- package/lib/view/assets/evidence.js +64 -0
- package/lib/view/assets/facts.js +100 -0
- package/lib/view/assets/form.js +211 -0
- package/lib/view/assets/goal.html +92 -0
- package/lib/view/assets/goal.js +638 -0
- package/lib/view/assets/insights.js +188 -0
- package/lib/view/assets/key.js +355 -0
- package/lib/view/assets/prefs.js +250 -0
- package/lib/view/assets/private-text.js +41 -0
- package/lib/view/assets/problems.css +191 -0
- package/lib/view/assets/problems.html +75 -0
- package/lib/view/assets/problems.js +1069 -0
- package/lib/view/assets/replay-model.js +704 -0
- package/lib/view/assets/replay.css +306 -0
- package/lib/view/assets/replay.html +122 -0
- package/lib/view/assets/replay.js +2079 -0
- package/lib/view/assets/search.html +48 -0
- package/lib/view/assets/search.js +615 -0
- package/lib/view/assets/sessions.js +144 -0
- package/lib/view/assets/settings.html +99 -0
- package/lib/view/assets/settings.js +183 -0
- package/lib/view/assets/setup.html +96 -0
- package/lib/view/assets/setup.js +131 -0
- package/lib/view/assets/strip.js +465 -0
- package/lib/view/codex-judge.mjs +411 -0
- package/lib/view/data.mjs +1339 -0
- package/lib/view/facts.mjs +306 -0
- package/lib/view/insights.mjs +309 -0
- package/lib/view/leaks.mjs +252 -0
- package/lib/view/problems-route.mjs +425 -0
- package/lib/view/progressive.mjs +134 -0
- package/lib/view/replay-export.mjs +252 -0
- package/lib/view/selftest/clickthrough.html +32 -0
- package/lib/view/selftest/clickthrough.js +2678 -0
- package/lib/view/server.mjs +336 -0
- package/lib/view/settings.mjs +369 -0
- package/lib/view/setup.mjs +286 -0
- package/lib/view/window.mjs +204 -0
- package/lib/view/word-index.mjs +192 -0
- package/lib/view.mjs +572 -0
- package/lib/voice-fence.mjs +213 -0
- package/lib/windows-root.mjs +17 -0
- package/lib/worktrees.mjs +260 -0
- 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
|
+
}
|