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/client.mjs
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// lib/client.mjs — the model behind the `client` output mode: a report of the work
|
|
2
|
+
// done for one client over any period (a sprint, a month, a contract to date), meant
|
|
3
|
+
// to be handed to that client.
|
|
4
|
+
//
|
|
5
|
+
// Honesty model, the same posture as every other mode:
|
|
6
|
+
// - build has already verify-or-aborted every cited commit (resolves, authored by
|
|
7
|
+
// a configured identity) and run the landed gate before this runs;
|
|
8
|
+
// - every NUMBER here is honestweek's own git derivation: pull requests and commits
|
|
9
|
+
// that reached the default branch, authored by you, dated inside the period. A
|
|
10
|
+
// repo that cannot be read yields a null, never a 0;
|
|
11
|
+
// - PR numbers are parsed from the verified commit subjects, never authored;
|
|
12
|
+
// - a cited commit dated outside the period aborts the build (ClientReportError):
|
|
13
|
+
// a report must not credit work to a period it did not happen in;
|
|
14
|
+
// - curated strings (titles, summaries, the headline) are trusted, redacted by the
|
|
15
|
+
// build, and HTML-escaped at render.
|
|
16
|
+
// Pure apart from the git reads in deriveClientStats.
|
|
17
|
+
|
|
18
|
+
import { landedCommitsInWindow, prNumberFromSubject } from './git.mjs';
|
|
19
|
+
import { STATUSES } from './badges.mjs';
|
|
20
|
+
|
|
21
|
+
export class ClientReportError extends Error {}
|
|
22
|
+
|
|
23
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
24
|
+
const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
25
|
+
const MAX_WEEK_BUCKETS = 40;
|
|
26
|
+
|
|
27
|
+
/** What each badge means in a client's words. `shipped` says exactly what the
|
|
28
|
+
* landed gate proves (on the default branch), not that it is live in production. */
|
|
29
|
+
export const CLIENT_STATUS = Object.freeze({
|
|
30
|
+
shipped: { label: 'Merged', key: 'merged', meaning: 'on the main branch; every cited commit checked against git' },
|
|
31
|
+
'in progress': { label: 'In progress', key: 'progress', meaning: "real work that isn't finished, or isn't on the main branch yet" },
|
|
32
|
+
'designed, not proven': { label: 'Designed', key: 'designed', meaning: 'the approach exists; no real result yet' },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function ymd(date) {
|
|
36
|
+
return date.toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
38
|
+
function parseYmd(s) {
|
|
39
|
+
const d = new Date(`${String(s).slice(0, 10)}T00:00:00.000Z`);
|
|
40
|
+
if (Number.isNaN(d.getTime())) throw new ClientReportError(`"${s}" is not a YYYY-MM-DD date.`);
|
|
41
|
+
return d;
|
|
42
|
+
}
|
|
43
|
+
function addDays(s, n) {
|
|
44
|
+
const d = parseYmd(s);
|
|
45
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
46
|
+
return ymd(d);
|
|
47
|
+
}
|
|
48
|
+
/** The Monday on or before a YYYY-MM-DD date. */
|
|
49
|
+
function mondayOf(s) {
|
|
50
|
+
const d = parseYmd(s);
|
|
51
|
+
const back = (d.getUTCDay() + 6) % 7;
|
|
52
|
+
d.setUTCDate(d.getUTCDate() - back);
|
|
53
|
+
return ymd(d);
|
|
54
|
+
}
|
|
55
|
+
export function dayLabel(s) {
|
|
56
|
+
if (!s) return '';
|
|
57
|
+
const d = parseYmd(s);
|
|
58
|
+
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
|
|
59
|
+
}
|
|
60
|
+
export function periodLabel(start, end) {
|
|
61
|
+
const a = parseYmd(start);
|
|
62
|
+
const b = parseYmd(end);
|
|
63
|
+
const sameYear = a.getUTCFullYear() === b.getUTCFullYear();
|
|
64
|
+
const left = `${MONTHS_LONG[a.getUTCMonth()]} ${a.getUTCDate()}${sameYear ? '' : `, ${a.getUTCFullYear()}`}`;
|
|
65
|
+
return `${left} to ${MONTHS_LONG[b.getUTCMonth()]} ${b.getUTCDate()}, ${b.getUTCFullYear()}`;
|
|
66
|
+
}
|
|
67
|
+
/** A commit's calendar date as its author saw it (the offset git recorded). */
|
|
68
|
+
function authorDay(dateISO) {
|
|
69
|
+
return String(dateISO ?? '').slice(0, 10);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Buckets for the activity chart: weeks (Monday start) while they fit, else months. */
|
|
73
|
+
function buildSeries(start, end, days) {
|
|
74
|
+
const weekCount = Math.floor((parseYmd(mondayOf(end)) - parseYmd(mondayOf(start))) / (7 * 86400000)) + 1;
|
|
75
|
+
const unit = weekCount <= MAX_WEEK_BUCKETS ? 'week' : 'month';
|
|
76
|
+
const buckets = [];
|
|
77
|
+
if (unit === 'week') {
|
|
78
|
+
for (let w = mondayOf(start); w <= end; w = addDays(w, 7)) {
|
|
79
|
+
const d = parseYmd(w);
|
|
80
|
+
buckets.push({ key: w, label: `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`, month: d.getUTCMonth(), count: 0 });
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const a = parseYmd(start);
|
|
84
|
+
const b = parseYmd(end);
|
|
85
|
+
for (let y = a.getUTCFullYear(), m = a.getUTCMonth(); y < b.getUTCFullYear() || (y === b.getUTCFullYear() && m <= b.getUTCMonth()); m === 11 ? (y++, (m = 0)) : m++) {
|
|
86
|
+
buckets.push({ key: `${y}-${String(m + 1).padStart(2, '0')}`, label: `${MONTHS[m]} ${y}`, month: m, count: 0 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const index = new Map(buckets.map((b, i) => [b.key, i]));
|
|
90
|
+
for (const day of days) {
|
|
91
|
+
const key = unit === 'week' ? mondayOf(day) : day.slice(0, 7);
|
|
92
|
+
const i = index.get(key);
|
|
93
|
+
if (i !== undefined) buckets[i].count += 1;
|
|
94
|
+
}
|
|
95
|
+
return { unit, buckets, max: buckets.reduce((m, b) => Math.max(m, b.count), 0) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* deriveClientStats({ config, period }) -> git-derived numbers for the period.
|
|
100
|
+
* Reads every featured/reference repo (display repos are never git-read). A repo
|
|
101
|
+
* that cannot be read, or has no determinable default branch, is listed in
|
|
102
|
+
* `unreadable` and makes the totals null rather than silently smaller.
|
|
103
|
+
*/
|
|
104
|
+
export function deriveClientStats({ config, period }) {
|
|
105
|
+
const sinceISO = `${period.start}T00:00:00.000Z`;
|
|
106
|
+
const untilISO = `${period.end}T23:59:59.999Z`;
|
|
107
|
+
const unreadable = [];
|
|
108
|
+
const commits = [];
|
|
109
|
+
for (const repo of config.repos ?? []) {
|
|
110
|
+
if (repo.role === 'display') continue;
|
|
111
|
+
const got = landedCommitsInWindow(repo.resolvedPath ?? repo.path, config.identity.authorEmails, sinceISO, untilISO);
|
|
112
|
+
if (!got) {
|
|
113
|
+
unreadable.push(repo.label);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const c of got.commits) commits.push({ ...c, repo: repo.label });
|
|
117
|
+
}
|
|
118
|
+
const nonMerge = commits.filter((c) => !c.isMerge);
|
|
119
|
+
// A merge commit is never credit: merging a teammate's pull request is not writing it.
|
|
120
|
+
// Every date here is when the commit LANDED on the default branch (landedISO), the
|
|
121
|
+
// same clock that decided it belongs to the period.
|
|
122
|
+
const prMap = new Map();
|
|
123
|
+
const landed = new Map(); // repoShaKey(repo, full sha) -> { landedISO, pr }
|
|
124
|
+
for (const c of nonMerge) {
|
|
125
|
+
landed.set(repoShaKey(c.repo, c.sha), { landedISO: c.landedISO, pr: c.pr });
|
|
126
|
+
if (c.pr == null) continue;
|
|
127
|
+
const key = `${c.repo}#${c.pr}`;
|
|
128
|
+
const prev = prMap.get(key);
|
|
129
|
+
if (!prev || c.landedISO < prev.dateISO) prMap.set(key, { repo: c.repo, pr: c.pr, sha: c.sha, shortSha: c.shortSha, subject: c.subject, dateISO: c.landedISO });
|
|
130
|
+
}
|
|
131
|
+
const days = new Set(nonMerge.map((c) => authorDay(c.landedISO)).filter(Boolean));
|
|
132
|
+
const complete = unreadable.length === 0;
|
|
133
|
+
return {
|
|
134
|
+
complete,
|
|
135
|
+
unreadable,
|
|
136
|
+
landed,
|
|
137
|
+
prs: [...prMap.values()].sort((a, b) => String(a.dateISO).localeCompare(String(b.dateISO)) || a.pr - b.pr),
|
|
138
|
+
totals: {
|
|
139
|
+
prs: complete ? prMap.size : null,
|
|
140
|
+
commits: complete ? nonMerge.length : null,
|
|
141
|
+
activeDays: complete ? days.size : null,
|
|
142
|
+
directCommits: complete ? nonMerge.filter((c) => c.pr == null).length : null,
|
|
143
|
+
},
|
|
144
|
+
series: buildSeries(period.start, period.end, nonMerge.map((c) => authorDay(c.landedISO))),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Commit evidence is keyed by repo AND sha: the same commit can sit in two configured
|
|
149
|
+
* repos (a fork and its upstream), and a sha-only key would let one overwrite the other. */
|
|
150
|
+
function repoShaKey(repo, sha) {
|
|
151
|
+
return `${JSON.stringify(repo ?? null)}\u0000${sha}`;
|
|
152
|
+
}
|
|
153
|
+
/** The entry for (repo, sha) in a repoShaKey map, accepting an abbreviated sha. */
|
|
154
|
+
function lookupByRepoSha(map, repo, sha) {
|
|
155
|
+
const exact = map.get(repoShaKey(repo, sha));
|
|
156
|
+
if (exact) return exact;
|
|
157
|
+
const prefix = repoShaKey(repo, sha);
|
|
158
|
+
for (const [k, v] of map) if (k.startsWith(prefix)) return v;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A PR subject as a reader would say it: no "(#12)", no "feat(scope): " prefix. */
|
|
163
|
+
export function readableTitle(subject) {
|
|
164
|
+
const t = String(subject ?? '')
|
|
165
|
+
.replace(/\s*\(#\d+\)\s*$/, '')
|
|
166
|
+
.replace(/^[a-z]+(\([^)]*\))?!?:\s+/, '')
|
|
167
|
+
.trim();
|
|
168
|
+
return t ? t[0].toUpperCase() + t.slice(1) : String(subject ?? '');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Issue numbers a commit message names ("#519", "Fixes #504"), minus its own PR number. */
|
|
172
|
+
export function issueRefs(message, ownPr = null) {
|
|
173
|
+
const out = new Set();
|
|
174
|
+
for (const m of String(message ?? '').matchAll(/(?:^|[^\w/&])#(\d{1,6})\b/g)) {
|
|
175
|
+
const n = Number(m[1]);
|
|
176
|
+
if (n !== ownPr) out.add(n);
|
|
177
|
+
}
|
|
178
|
+
return [...out];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A row for a section that gathers items from across the areas. It keeps the item's
|
|
182
|
+
* receipts (its pull requests and commits), so every row still points to its source. */
|
|
183
|
+
function compact(v, matched = []) {
|
|
184
|
+
return { id: v.id, title: v.title, statusLabel: v.statusLabel, statusKey: v.statusKey, dateLabel: v.dateLabel, matched, prs: v.prs, commits: v.commits };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* applyView({ profile, themes, views, content }) -> { themes, shownIds, model }
|
|
189
|
+
* The reader's view over the finished items. Rules it keeps (docs/reader-profiles.md):
|
|
190
|
+
* items are never edited; an excluded area is counted, never silently dropped; the
|
|
191
|
+
* record and the method are always in the order; a tag that no section picks, or an
|
|
192
|
+
* exclusion naming no area, is refused as a likely typo.
|
|
193
|
+
*/
|
|
194
|
+
export function applyView({ profile, themes, views, content }) {
|
|
195
|
+
// A report states what was done, with evidence. An ask ("what I need from you") has
|
|
196
|
+
// none and turns the report into a negotiation; it belongs in the message you send
|
|
197
|
+
// with the report, so the items file can't carry one.
|
|
198
|
+
if (content && content.needs !== undefined) {
|
|
199
|
+
throw new ClientReportError('content.needs isn\'t supported: a report says what was done, with evidence. Put asks in the message you send with it.');
|
|
200
|
+
}
|
|
201
|
+
const p = profile ?? { order: ['highlights', 'activity', 'done', 'not-finished', 'next', 'record', 'how'], sections: [], exclude: { themes: [] }, format: { note: false }, guidance: [], unconfirmed: false };
|
|
202
|
+
const themeIds = new Set(themes.map((t) => t.id));
|
|
203
|
+
for (const id of p.exclude.themes) {
|
|
204
|
+
if (!themeIds.has(id)) throw new ClientReportError(`the reader profile excludes area ${JSON.stringify(id)}, but no change in this report is in that area.`);
|
|
205
|
+
}
|
|
206
|
+
const pickedTags = new Set(p.sections.flatMap((s) => s.select.tags ?? []));
|
|
207
|
+
for (const v of views) {
|
|
208
|
+
const stray = v.tags.find((t) => !pickedTags.has(t));
|
|
209
|
+
if (stray) throw new ClientReportError(`item ${v.id} is tagged ${JSON.stringify(stray)}, but no section of the reader profile picks that tag.`);
|
|
210
|
+
}
|
|
211
|
+
const excluded = new Set(p.exclude.themes);
|
|
212
|
+
// Excluding an area leaves its changes out of every section of this view, not just the
|
|
213
|
+
// areas list; the page counts them instead.
|
|
214
|
+
const visible = views.filter((v) => !excluded.has(v.theme));
|
|
215
|
+
const shownThemes = themes.filter((t) => !excluded.has(t.id));
|
|
216
|
+
const hiddenThemes = themes.filter((t) => excluded.has(t.id));
|
|
217
|
+
const sections = p.sections.map((s) => {
|
|
218
|
+
const byIssues = Array.isArray(s.select.issues);
|
|
219
|
+
const wanted = new Set(byIssues ? s.select.issues : s.select.tags);
|
|
220
|
+
const rows = visible
|
|
221
|
+
.map((v) => ({ v, hits: byIssues ? v.issues.filter((n) => wanted.has(n)) : v.tags.filter((t) => wanted.has(t)) }))
|
|
222
|
+
.filter((x) => x.hits.length)
|
|
223
|
+
.sort((a, b) => String(b.v.date || '').localeCompare(String(a.v.date || '')))
|
|
224
|
+
.map((x) => compact(x.v, x.hits));
|
|
225
|
+
return { id: s.id, title: s.title, summary: s.summary, pickedBy: byIssues ? 'git' : 'hand', wanted: [...wanted], rows };
|
|
226
|
+
});
|
|
227
|
+
const notFinished = visible.filter((v) => v.status !== 'shipped').map((v) => compact(v));
|
|
228
|
+
const shownIds = new Set([
|
|
229
|
+
...shownThemes.flatMap((t) => t.items.map((v) => v.id)),
|
|
230
|
+
...sections.flatMap((s) => s.rows.map((r) => r.id)),
|
|
231
|
+
...notFinished.map((r) => r.id),
|
|
232
|
+
]);
|
|
233
|
+
const hiddenItems = hiddenThemes.reduce((n, t) => n + t.items.length, 0);
|
|
234
|
+
const paragraphs = (v) => (Array.isArray(v) ? v : v ? [v] : []).map(String).filter((x) => x.trim());
|
|
235
|
+
return {
|
|
236
|
+
themes: shownThemes,
|
|
237
|
+
shownIds,
|
|
238
|
+
model: {
|
|
239
|
+
order: p.order,
|
|
240
|
+
sections,
|
|
241
|
+
notFinished,
|
|
242
|
+
hidden: { items: hiddenItems, areas: hiddenThemes.map((t) => t.title) },
|
|
243
|
+
note: p.format.note === true,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function prLinkFor(config, repo, n) {
|
|
249
|
+
const base = config.client?.prLinks?.[repo];
|
|
250
|
+
return base ? `${base}${n}` : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* buildClientModel({ items, config, verified, period, content, stats, now }) -> the client page model.
|
|
255
|
+
* `verified` is build's verify-or-abort result (each { sha, shortSha, subject,
|
|
256
|
+
* dateISO, repoLabel, landed }). Throws ClientReportError on a cited commit dated
|
|
257
|
+
* outside the period.
|
|
258
|
+
*/
|
|
259
|
+
export function buildClientModel({ items = [], config, verified = [], period, content = null, stats, now = new Date(), profile = null, messageFor = null }) {
|
|
260
|
+
if (!period?.start || !period?.end || period.start > period.end) {
|
|
261
|
+
throw new ClientReportError('the report period needs a start on or before its end.');
|
|
262
|
+
}
|
|
263
|
+
const byRepoSha = new Map();
|
|
264
|
+
for (const v of verified) byRepoSha.set(repoShaKey(v.repoLabel, v.sha), v);
|
|
265
|
+
const landedMap = stats.landed ?? new Map();
|
|
266
|
+
const c = content && typeof content === 'object' ? content : {};
|
|
267
|
+
|
|
268
|
+
const declared = Array.isArray(c.themes) ? c.themes.filter((t) => t && t.id) : [];
|
|
269
|
+
const themeById = new Map(declared.map((t) => [String(t.id), { id: String(t.id), title: String(t.title || t.id), summary: t.summary ?? '', items: [] }]));
|
|
270
|
+
const other = { id: 'other', title: 'Other work', summary: '', items: [] };
|
|
271
|
+
|
|
272
|
+
const citedPrs = new Set();
|
|
273
|
+
const views = items.map((item, i) => {
|
|
274
|
+
const shas = [...new Set([item.primaryCommit, item.commit, item.receipt?.primaryCommit, ...(Array.isArray(item.commits) ? item.commits : [])].filter((s) => typeof s === 'string' && s))];
|
|
275
|
+
const commits = [];
|
|
276
|
+
const issues = new Set();
|
|
277
|
+
const repo = item.repo ?? item.repoLabel ?? item.label ?? null;
|
|
278
|
+
for (const sha of shas) {
|
|
279
|
+
const v = lookupByRepoSha(byRepoSha, repo, sha);
|
|
280
|
+
if (!v) continue;
|
|
281
|
+
// A landed commit is dated by when it landed, the same clock the period uses. One
|
|
282
|
+
// that is on the default branch but not in this period's landed set landed outside
|
|
283
|
+
// it. Unlanded work (build downgraded it to in progress) is dated by its author date.
|
|
284
|
+
const land = lookupByRepoSha(landedMap, repo, v.sha);
|
|
285
|
+
const day = authorDay(land ? land.landedISO : v.dateISO);
|
|
286
|
+
if (!land && v.landed === true) {
|
|
287
|
+
throw new ClientReportError(`item ${item.id ?? `item[${i}]`} cites commit ${v.shortSha}, which landed on the default branch outside the report period ${period.start} to ${period.end}.`);
|
|
288
|
+
}
|
|
289
|
+
if (day < period.start || day > period.end) {
|
|
290
|
+
throw new ClientReportError(`item ${item.id ?? `item[${i}]`} cites commit ${v.shortSha} dated ${day}, outside the report period ${period.start} to ${period.end}.`);
|
|
291
|
+
}
|
|
292
|
+
const pr = prNumberFromSubject(v.subject) ?? land?.pr ?? null;
|
|
293
|
+
if (messageFor) for (const n of issueRefs(messageFor(repo, v.sha), pr)) issues.add(n);
|
|
294
|
+
commits.push({ sha: v.sha, shortSha: v.shortSha, subject: v.subject, date: day, repo, pr });
|
|
295
|
+
}
|
|
296
|
+
commits.sort((a, b) => a.date.localeCompare(b.date));
|
|
297
|
+
const prs = [];
|
|
298
|
+
for (const cm of commits) {
|
|
299
|
+
if (cm.pr == null || prs.some((p) => p.n === cm.pr && p.repo === cm.repo)) continue;
|
|
300
|
+
prs.push({ n: cm.pr, repo: cm.repo, url: prLinkFor(config, cm.repo, cm.pr), shortSha: cm.shortSha });
|
|
301
|
+
}
|
|
302
|
+
// The item's own primary receipt leads: its pull request is listed first, so a short
|
|
303
|
+
// reference (a row, the note) names the change's main PR, not whichever merged first.
|
|
304
|
+
const primary = item.receipt?.primaryCommit ?? item.primaryCommit ?? null;
|
|
305
|
+
const lead = primary ? commits.find((cm) => cm.sha.startsWith(primary) || primary.startsWith(cm.sha)) : null;
|
|
306
|
+
if (lead?.pr != null) {
|
|
307
|
+
const i = prs.findIndex((p) => p.n === lead.pr && p.repo === lead.repo);
|
|
308
|
+
if (i > 0) prs.unshift(...prs.splice(i, 1));
|
|
309
|
+
}
|
|
310
|
+
const status = STATUSES.includes(item.status) ? item.status : 'designed, not proven';
|
|
311
|
+
const date = commits.length ? commits[commits.length - 1].date : item.date ?? null;
|
|
312
|
+
const view = {
|
|
313
|
+
id: item.id != null && String(item.id).trim() ? String(item.id) : `w${i}`,
|
|
314
|
+
title: String(item.title || item.summary || item.text || 'Untitled').trim(),
|
|
315
|
+
summary: item.summary ?? item.text ?? '',
|
|
316
|
+
status,
|
|
317
|
+
statusLabel: CLIENT_STATUS[status].label,
|
|
318
|
+
statusKey: CLIENT_STATUS[status].key,
|
|
319
|
+
date,
|
|
320
|
+
dateLabel: date ? dayLabel(date) : '',
|
|
321
|
+
highlight: item.highlight === true,
|
|
322
|
+
prs,
|
|
323
|
+
commits,
|
|
324
|
+
theme: themeById.has(String(item.theme)) ? String(item.theme) : 'other',
|
|
325
|
+
issues: [...issues].sort((a, b) => a - b),
|
|
326
|
+
tags: Array.isArray(item.tags) ? item.tags.map(String) : [],
|
|
327
|
+
};
|
|
328
|
+
(themeById.get(view.theme) || other).items.push(view);
|
|
329
|
+
return view;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const themes = [...themeById.values(), other]
|
|
333
|
+
.filter((t) => t.items.length)
|
|
334
|
+
.map((t) => {
|
|
335
|
+
t.items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
336
|
+
const prKeys = new Set(t.items.flatMap((it) => it.prs.map((p) => `${p.repo}#${p.n}`)));
|
|
337
|
+
return { ...t, anchor: `area-${t.id}`, counts: { items: t.items.length, prs: prKeys.size } };
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// --- the reader's view (lib/reader.mjs). It selects and orders; it never edits an item.
|
|
341
|
+
const view = applyView({ profile, themes, views, content: c });
|
|
342
|
+
for (const v of views) if (view.shownIds.has(v.id)) for (const p of v.prs) citedPrs.add(`${p.repo}#${p.n}`);
|
|
343
|
+
|
|
344
|
+
const appendix = stats.prs.map((p) => ({
|
|
345
|
+
repo: p.repo,
|
|
346
|
+
n: p.pr,
|
|
347
|
+
url: prLinkFor(config, p.repo, p.pr),
|
|
348
|
+
date: authorDay(p.dateISO),
|
|
349
|
+
dateLabel: dayLabel(authorDay(p.dateISO)),
|
|
350
|
+
title: readableTitle(p.subject),
|
|
351
|
+
cited: citedPrs.has(`${p.repo}#${p.pr}`),
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
const statusCounts = {};
|
|
355
|
+
for (const v of views) statusCounts[v.statusKey] = (statusCounts[v.statusKey] || 0) + 1;
|
|
356
|
+
const present = new Set(views.map((v) => v.status));
|
|
357
|
+
const tz = config.week?.timezone || 'UTC';
|
|
358
|
+
const generatedOn = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
|
359
|
+
const cl = config.client || {};
|
|
360
|
+
const paragraphs = (v) => (Array.isArray(v) ? v : v ? [v] : []).map(String).filter((s) => s.trim());
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
kind: 'client',
|
|
364
|
+
client: {
|
|
365
|
+
name: cl.name ?? '',
|
|
366
|
+
preparedFor: cl.preparedFor ?? '',
|
|
367
|
+
preparedBy: cl.preparedBy ?? '',
|
|
368
|
+
organization: cl.organization ?? '',
|
|
369
|
+
},
|
|
370
|
+
period: { start: period.start, end: period.end, label: periodLabel(period.start, period.end) },
|
|
371
|
+
generatedOn,
|
|
372
|
+
generatedLabel: periodLabel(generatedOn, generatedOn).replace(/^.* to /, ''),
|
|
373
|
+
title: c.title || `${cl.name || 'Client'} work report`,
|
|
374
|
+
headline: c.headline || '',
|
|
375
|
+
summary: paragraphs(c.summary),
|
|
376
|
+
next: paragraphs(c.next),
|
|
377
|
+
stats: {
|
|
378
|
+
complete: stats.complete,
|
|
379
|
+
unreadable: stats.unreadable,
|
|
380
|
+
prs: stats.totals.prs,
|
|
381
|
+
commits: stats.totals.commits,
|
|
382
|
+
activeDays: stats.totals.activeDays,
|
|
383
|
+
directCommits: stats.totals.directCommits,
|
|
384
|
+
areas: themes.length,
|
|
385
|
+
itemsTotal: views.length,
|
|
386
|
+
statusCounts,
|
|
387
|
+
prsCited: appendix.filter((a) => a.cited).length,
|
|
388
|
+
},
|
|
389
|
+
series: stats.series,
|
|
390
|
+
legend: Object.entries(CLIENT_STATUS).filter(([k]) => present.has(k)).map(([, v]) => v),
|
|
391
|
+
highlights: views.filter((v) => v.highlight && view.shownIds.has(v.id)),
|
|
392
|
+
themes: view.themes,
|
|
393
|
+
view: view.model,
|
|
394
|
+
appendix,
|
|
395
|
+
provenance: { commitsVerified: new Set(views.flatMap((v) => v.commits.map((x) => x.sha))).size },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// lib/codex-records.mjs — who said what in a Codex session log.
|
|
2
|
+
//
|
|
3
|
+
// Codex has written a person's turn in two shapes, and honestweek's three Codex
|
|
4
|
+
// readers (the miner's corpus, the digest's evidence, the prompt collector) must
|
|
5
|
+
// understand both or they go blind without erroring:
|
|
6
|
+
//
|
|
7
|
+
// legacy : {type:"event_msg", payload:{type:"user_message", message: string}}
|
|
8
|
+
// {type:"event_msg", payload:{type:"agent_message", message: string}}
|
|
9
|
+
// current: {type:"response_item", payload:{type:"message", role:"user",
|
|
10
|
+
// content:[{type:"input_text", text}]}}
|
|
11
|
+
// {type:"response_item", payload:{type:"message", role:"assistant",
|
|
12
|
+
// content:[{type:"output_text", text}]}}
|
|
13
|
+
//
|
|
14
|
+
// MEASURED on this machine, 2026-09-23: 3,633 rollout files, none containing a
|
|
15
|
+
// legacy `user_message` record. 11,589 current-shape user messages, and most of
|
|
16
|
+
// them are not a person: the harness sends its context (environment, AGENTS.md,
|
|
17
|
+
// plugin lists) and other agents' hand-offs through the same `role:"user"` slot.
|
|
18
|
+
// The first person-typed block ended past byte 76,000 in half the files, past
|
|
19
|
+
// 106,000 in 90%, and past 247,000 in 99%, which is why a 64 KB head probe can't
|
|
20
|
+
// see it (see CODEX_HEAD_MAX_BYTES).
|
|
21
|
+
//
|
|
22
|
+
// Two rules, both under-claiming when unsure:
|
|
23
|
+
// - A block that is harness CONTEXT is dropped, and the rest of the message is
|
|
24
|
+
// still read. A person's text can share a message with an injected context block.
|
|
25
|
+
// - A block that marks the whole message as MACHINE-AUTHORED (another agent's
|
|
26
|
+
// delegation, a heartbeat, an automation, the approval reviewer's packet)
|
|
27
|
+
// rejects the message, because the text around it was written by that machine.
|
|
28
|
+
//
|
|
29
|
+
// Older Codex builds wrote BOTH shapes for one turn. `createCodexTurnReader`
|
|
30
|
+
// drops the second copy so a turn is never counted twice.
|
|
31
|
+
//
|
|
32
|
+
// A session started with `codex exec` is agent-started (the owner's decision on
|
|
33
|
+
// issue 62): its session_meta says `source: "exec"` (originator `codex_exec`), and
|
|
34
|
+
// another agent or a script usually wrote what it was asked. None of its user
|
|
35
|
+
// messages count as something the person typed. `createCodexTurnReader` reads that
|
|
36
|
+
// from the session_meta record it's given, so every reader that numbers turns
|
|
37
|
+
// through it agrees on which turns are a person's.
|
|
38
|
+
//
|
|
39
|
+
// `response_item` records of type `agent_message` are messages BETWEEN agents
|
|
40
|
+
// (a subagent reporting to its root), not the assistant talking to the person,
|
|
41
|
+
// so they are deliberately not read as assistant text.
|
|
42
|
+
//
|
|
43
|
+
// Zero runtime dependencies: Node built-ins only.
|
|
44
|
+
|
|
45
|
+
/** Largest head a Codex probe reads while looking for the first human turn. Covers
|
|
46
|
+
* the measured 99th percentile (≈247 KB) with room; a file whose first person-typed
|
|
47
|
+
* turn sits past this is treated as having none, which under-claims. */
|
|
48
|
+
export const CODEX_HEAD_MAX_BYTES = 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
/** A block that makes the WHOLE message machine-authored. */
|
|
51
|
+
const MACHINE_AUTHORED_RE = new RegExp(
|
|
52
|
+
'^(?:' +
|
|
53
|
+
[
|
|
54
|
+
'<(?:codex_delegation|realtime_delegation|heartbeat)\\b',
|
|
55
|
+
'<!--\\s*command:',
|
|
56
|
+
'Automation:',
|
|
57
|
+
'>>> (?:APPROVAL REQUEST|TRANSCRIPT)',
|
|
58
|
+
'The following is the Codex agent history',
|
|
59
|
+
'The Codex agent has requested',
|
|
60
|
+
'Reviewed Codex session id:',
|
|
61
|
+
].join('|') +
|
|
62
|
+
')',
|
|
63
|
+
'i',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** A block that is injected context: any leading tag or HTML comment, or the
|
|
67
|
+
* AGENTS.md preamble. Open-ended on purpose, because a closed list of tag names is
|
|
68
|
+
* exactly what went stale when Codex added new ones. */
|
|
69
|
+
const CONTEXT_BLOCK_RE = /^(?:<[A-Za-z_][\w-]*(?:[\s>/]|$)|<!--|# AGENTS\.md\b)/;
|
|
70
|
+
|
|
71
|
+
/** The IDE extension wraps a person's request in its own context (open file, open
|
|
72
|
+
* tabs, mentioned files) and marks where the request starts. Only the text after
|
|
73
|
+
* that marker is the person's; a block with no marker is all context. */
|
|
74
|
+
const IDE_CONTEXT_RE = /^# (?:Context from my IDE setup|Files mentioned by the user):/;
|
|
75
|
+
const IDE_REQUEST_MARKER = '## My request for Codex:';
|
|
76
|
+
|
|
77
|
+
function blockTexts(content, blockType) {
|
|
78
|
+
if (typeof content === 'string') return [content];
|
|
79
|
+
if (!Array.isArray(content)) return [];
|
|
80
|
+
return content.filter((b) => b && b.type === blockType && typeof b.text === 'string').map((b) => b.text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The person-typed part of a list of blocks, or null when there is none. */
|
|
84
|
+
function personText(blocks) {
|
|
85
|
+
const kept = [];
|
|
86
|
+
for (const raw of blocks) {
|
|
87
|
+
const t = raw.trim();
|
|
88
|
+
if (!t) continue;
|
|
89
|
+
if (MACHINE_AUTHORED_RE.test(t)) return null;
|
|
90
|
+
if (IDE_CONTEXT_RE.test(t)) {
|
|
91
|
+
const at = t.indexOf(IDE_REQUEST_MARKER);
|
|
92
|
+
const request = at < 0 ? '' : t.slice(at + IDE_REQUEST_MARKER.length).trim();
|
|
93
|
+
if (request) kept.push(request);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (CONTEXT_BLOCK_RE.test(t)) continue;
|
|
97
|
+
kept.push(raw);
|
|
98
|
+
}
|
|
99
|
+
return kept.length ? kept.join('\n') : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when a session_meta payload says `codex exec` started the session. The
|
|
103
|
+
* fields must match exactly, so a session that only mentions exec isn't one. */
|
|
104
|
+
export function isCodexExecSession(meta) {
|
|
105
|
+
if (!meta || typeof meta !== 'object') return false;
|
|
106
|
+
return meta.source === 'exec' || meta.originator === 'codex_exec';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The human text of one Codex record, in either shape, or null. Stateless: a
|
|
110
|
+
* caller reading a whole file wants `createCodexTurnReader` instead. */
|
|
111
|
+
export function codexUserText(rec) {
|
|
112
|
+
const p = rec?.payload;
|
|
113
|
+
if (!p || typeof p !== 'object') return null;
|
|
114
|
+
if (rec.type === 'event_msg' && p.type === 'user_message' && typeof p.message === 'string') return personText([p.message]);
|
|
115
|
+
if (rec.type === 'response_item' && p.type === 'message' && p.role === 'user') return personText(blockTexts(p.content, 'input_text'));
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The assistant's text to the person in one Codex record, in either shape, or null. */
|
|
120
|
+
export function codexAssistantText(rec) {
|
|
121
|
+
const p = rec?.payload;
|
|
122
|
+
if (!p || typeof p !== 'object') return null;
|
|
123
|
+
let text = null;
|
|
124
|
+
if (rec.type === 'event_msg' && p.type === 'agent_message' && typeof p.message === 'string') text = p.message;
|
|
125
|
+
else if (rec.type === 'response_item' && p.type === 'message' && p.role === 'assistant') text = blockTexts(p.content, 'output_text').join('\n');
|
|
126
|
+
return text && text.trim() ? text : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A per-file reader that returns { user, assistant, instruction } text for each
|
|
131
|
+
* record, with the second copy of a dual-written turn dropped: a record whose text
|
|
132
|
+
* equals the previous accepted one of the same role, written in the OTHER shape, is
|
|
133
|
+
* the same turn. Two identical turns written in the same shape are both kept.
|
|
134
|
+
*
|
|
135
|
+
* Pass it every record, session_meta included. The file's first session_meta
|
|
136
|
+
* decides: in a `codex exec` session `user` is always null, and the text a
|
|
137
|
+
* person-shaped message carried comes back as `instruction`, the agent's.
|
|
138
|
+
*/
|
|
139
|
+
export function createCodexTurnReader() {
|
|
140
|
+
const last = { user: null, assistant: null };
|
|
141
|
+
let metaSeen = false;
|
|
142
|
+
let agentStarted = false;
|
|
143
|
+
const shape = (rec) => (rec.type === 'event_msg' ? 'legacy' : 'current');
|
|
144
|
+
const take = (role, rec, text) => {
|
|
145
|
+
if (text == null) return null;
|
|
146
|
+
const prev = last[role];
|
|
147
|
+
const key = text.trim();
|
|
148
|
+
if (prev && prev.key === key && prev.shape !== shape(rec)) {
|
|
149
|
+
last[role] = null;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
last[role] = { key, shape: shape(rec) };
|
|
153
|
+
return text;
|
|
154
|
+
};
|
|
155
|
+
return (rec) => {
|
|
156
|
+
if (!metaSeen && rec?.type === 'session_meta') {
|
|
157
|
+
metaSeen = true;
|
|
158
|
+
agentStarted = isCodexExecSession(rec.payload);
|
|
159
|
+
}
|
|
160
|
+
const text = take('user', rec, codexUserText(rec));
|
|
161
|
+
return {
|
|
162
|
+
user: agentStarted ? null : text,
|
|
163
|
+
assistant: take('assistant', rec, codexAssistantText(rec)),
|
|
164
|
+
instruction: agentStarted ? text : null,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
}
|