@golden-frijoles/kit 0.0.0 → 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/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +179 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// weekly-recap.mjs — gathers the week's merged PRs (every repo in reporting.config.json) + shipped/closed
|
|
3
|
+
// epics (README frontmatter status: flips) + a short retro digest, and posts a formatted weekly Telegram recap.
|
|
4
|
+
//
|
|
5
|
+
// Project values (repo list, deploy repos, chat) come from reporting.config.json via
|
|
6
|
+
// scripts/lib/reporting-config.mjs — see that module for why there is no default. Ported into the
|
|
7
|
+
// dobby-foundation template by plugin-audit-and-extraction S1; the logic is unchanged from the origin.
|
|
8
|
+
//
|
|
9
|
+
// "Deploys" = merged-PR counts on the configured deploy repos, not a live hosting-platform API read.
|
|
10
|
+
// Per WAYS-OF-WORKING.md, "merging to main IS the production deploy" — so this is exactly the number a
|
|
11
|
+
// human would get by manually tallying merges for the week (the sprint's own acceptance bar), and it
|
|
12
|
+
// avoids a new external API dependency this script would need real credentials for.
|
|
13
|
+
//
|
|
14
|
+
// "Shipped/closed epics" = epic README frontmatter status: flips to `shipped` or `archived` this window,
|
|
15
|
+
// detected by scanning `git log -p` diff hunks for an added `+status: shipped`/`+status: archived` line
|
|
16
|
+
// (the epic-close flip; SSOT per scripts/build-order.mjs). For each one found, pulls a short excerpt from
|
|
17
|
+
// its sibling RETROSPECTIVE.md (if present) as the "retro digest".
|
|
18
|
+
//
|
|
19
|
+
// Window: unlike standup.mjs's delta-snapshot diffing, this keeps a WINDOW tracker
|
|
20
|
+
// (scripts/weekly-recaps.log, JSONL) — next run's `since` = last logged `windowEnd` (falls back to
|
|
21
|
+
// now-7d on first run / missing log), so back-to-back runs don't double-count regardless of exact
|
|
22
|
+
// cadence drift. After a successful Telegram post, commits + pushes the updated log to `main`
|
|
23
|
+
// (path-scoped) — same push-beyond-`claude/`-prefix requirement standup.mjs already documents.
|
|
24
|
+
//
|
|
25
|
+
// Usage:
|
|
26
|
+
// node scripts/weekly-recap.mjs # gather, post to Telegram, commit+push the log
|
|
27
|
+
// node scripts/weekly-recap.mjs --dry-run # gather + print the message only — fully read-only
|
|
28
|
+
// node scripts/weekly-recap.mjs --since 2026-06-25T00:00:00Z # override the window start
|
|
29
|
+
// node scripts/weekly-recap.mjs --since 2026-06-01T00:00:00Z --until 2026-06-30T23:59:59Z # a bounded backfill window
|
|
30
|
+
//
|
|
31
|
+
// Reuse, don't rebuild: ensureGh()/die() (scripts/lib/cross-agent-cli.mjs), the same Telegram
|
|
32
|
+
// sendMessage shape scripts/standup.mjs already reimplements standalone (no access to the app's
|
|
33
|
+
// node_modules/TS build here). Zero npm deps — Node >=20 (global fetch, spawnSync).
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { readFileSync, existsSync, writeSync } from 'node:fs';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { dirname, join, resolve } from 'node:path';
|
|
39
|
+
import { ensureGh, die } from './lib/cross-agent-cli.mjs';
|
|
40
|
+
import { loadReportingConfig, chatIdFor, shortRepo, ReportingConfigError } from './lib/reporting-config.mjs';
|
|
41
|
+
import { searchMergedPrs } from './lib/gh-rest.mjs';
|
|
42
|
+
import { formatPrList, truncateForTelegram } from './lib/telegram-format.mjs';
|
|
43
|
+
import { readLogFromBranch, appendLineToBranch } from './lib/log-branch.mjs';
|
|
44
|
+
import { findingsToRevisionNote, judgeProse } from './lib/prose-guard.mjs';
|
|
45
|
+
import {
|
|
46
|
+
buildEvidencePack,
|
|
47
|
+
buildQuietBrief,
|
|
48
|
+
buildBrief,
|
|
49
|
+
deriveEvidenceFlags,
|
|
50
|
+
loadOwedLedger,
|
|
51
|
+
} from './lib/prose-brief.mjs';
|
|
52
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
53
|
+
|
|
54
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const ROOT = projectRoot(); // D2
|
|
56
|
+
|
|
57
|
+
// The window-tracking log lives on a dedicated `claude/`-prefixed branch, not committed to `main` — a
|
|
58
|
+
// routine's DEFAULT push scope already covers `claude/`-prefixed branches, so this needs no extra
|
|
59
|
+
// permission (see scripts/lib/log-branch.mjs for why: the "Allow unrestricted branch pushes" toggle
|
|
60
|
+
// failed to save live, 2026-07-02/03).
|
|
61
|
+
const LOG_BRANCH = 'claude/weekly-recap-log';
|
|
62
|
+
const LOG_BRANCH_PATH = 'weekly-recaps.log'; // flat filename — git mktree needs a single-level tree
|
|
63
|
+
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
const DRY_RUN = argv.includes('--dry-run');
|
|
66
|
+
|
|
67
|
+
// ── Two-phase prose modes (exec-prose-rail S3) — mirrors standup.mjs exactly ─────────────────
|
|
68
|
+
// Same contract, higher altitude and a longer budget. Deliberately NOT a shared main(): the two
|
|
69
|
+
// scripts have genuinely different gather stages and window strategies (see the header note on why
|
|
70
|
+
// the window tracker here is not the standup's delta-snapshot diffing).
|
|
71
|
+
const BRIEF = argv.includes('--brief');
|
|
72
|
+
const POST = argv.includes('--post');
|
|
73
|
+
const FORCE_POST = argv.includes('--force-post');
|
|
74
|
+
const PROSE_FILE = argValue('--prose-file');
|
|
75
|
+
const QUIET = argv.includes('--quiet');
|
|
76
|
+
|
|
77
|
+
// A weekly is read at a desk, not on a lock screen — but the budget is still a budget, not a target.
|
|
78
|
+
const WEEKLY_MAX_WORDS = 160;
|
|
79
|
+
function argValue(flag) {
|
|
80
|
+
const i = argv.indexOf(flag);
|
|
81
|
+
return i !== -1 ? argv[i + 1] : null;
|
|
82
|
+
}
|
|
83
|
+
const SINCE_OVERRIDE = argValue('--since');
|
|
84
|
+
const UNTIL_OVERRIDE = argValue('--until');
|
|
85
|
+
|
|
86
|
+
const RETRO_DIGEST_MAX_CHARS = 320;
|
|
87
|
+
const DEFAULT_WINDOW_DAYS = 7;
|
|
88
|
+
const MAX_PRS_SHOWN_PER_REPO = 12; // caps a busy-week PR listing before it dominates the message
|
|
89
|
+
// Caps the epic listing the same way MAX_PRS_SHOWN_PER_REPO caps PRs. There was NO cap here, which is
|
|
90
|
+
// how the 2026-08-24 run died: a ~6-week catch-up window (the last successful post was 2026-07-13) found
|
|
91
|
+
// 52 shipped/archived epics and rendered every one with its retro digest — each up to
|
|
92
|
+
// RETRO_DIGEST_MAX_CHARS. A per-section cap is the PRIMARY control; truncateForTelegram is only the net.
|
|
93
|
+
const MAX_EPICS_SHOWN = 10;
|
|
94
|
+
const TELEGRAM_MAX_CHARS = 4096; // Telegram sendMessage's hard text limit — a safety net, not the primary control
|
|
95
|
+
|
|
96
|
+
function esc(s) {
|
|
97
|
+
return String(s ?? '')
|
|
98
|
+
.replace(/&/g, '&')
|
|
99
|
+
.replace(/</g, '<')
|
|
100
|
+
.replace(/>/g, '>');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- window / memory log (JSONL, one line per run — lives on a dedicated branch, see above) ----
|
|
104
|
+
|
|
105
|
+
function loadLastRun() {
|
|
106
|
+
const content = readLogFromBranch({ cwd: ROOT, branch: LOG_BRANCH, path: LOG_BRANCH_PATH });
|
|
107
|
+
if (!content) return null;
|
|
108
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
109
|
+
if (!lines.length) return null;
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pure — a window tracker (not a delta diff like standup.mjs). Next run picks up exactly where the
|
|
118
|
+
// last one left off; a first run (or a wiped log) falls back to a plain trailing 7 days. `overrideUntilISO`
|
|
119
|
+
// lets a manual backfill bound the window (e.g. "what shipped in June") — the normal nightly/weekly path
|
|
120
|
+
// never sets it, so `untilISO` is always "now" there.
|
|
121
|
+
export function computeWindow(lastLogLine, now, overrideSinceISO, overrideUntilISO) {
|
|
122
|
+
const nowISO = now.toISOString();
|
|
123
|
+
const untilISO = overrideUntilISO || nowISO;
|
|
124
|
+
if (overrideSinceISO) return { sinceISO: overrideSinceISO, untilISO };
|
|
125
|
+
if (lastLogLine?.windowEnd) return { sinceISO: lastLogLine.windowEnd, untilISO };
|
|
126
|
+
const fallback = new Date(now.getTime() - DEFAULT_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
127
|
+
return { sinceISO: fallback.toISOString(), untilISO };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- gather: merged PRs per repo (REST-only — scripts/lib/gh-rest.mjs) ----
|
|
131
|
+
//
|
|
132
|
+
// `gh pr list --search` hits GraphQL internally, which is blocked in at least one live routine sandbox
|
|
133
|
+
// (confirmed 2026-07-02). searchMergedPrs() uses the REST-based /search/issues endpoint instead, which
|
|
134
|
+
// supports the same `merged:>=` day-granular qualifier and a `base:` filter.
|
|
135
|
+
|
|
136
|
+
// searchMergedPrs's own maxItems ceiling — set generously above what even a full-month backfill needs
|
|
137
|
+
// (the busiest repo here runs ~30-40 merged PRs/week, so a month is comfortably under 500) rather than a
|
|
138
|
+
// weekly-sized default, which a SKILL.md-documented month-long backfill could silently exceed with zero
|
|
139
|
+
// signal that it happened.
|
|
140
|
+
const MAX_PRS_FETCHED_PER_REPO = 500;
|
|
141
|
+
|
|
142
|
+
function gatherMergedPrs(repo, sinceISO, untilISO) {
|
|
143
|
+
const sinceDate = sinceISO.slice(0, 10);
|
|
144
|
+
const prs = searchMergedPrs({ repo, sinceDate, base: 'main', maxItems: MAX_PRS_FETCHED_PER_REPO });
|
|
145
|
+
if (prs === null) return { repo, available: false, prs: [], capped: false };
|
|
146
|
+
// The search's date qualifier is day-granular — filter to the real [sinceISO, untilISO) window so a
|
|
147
|
+
// run near midnight UTC doesn't double-count a PR merged just outside the boundary day, and a bounded
|
|
148
|
+
// backfill (--until) doesn't pull in PRs merged after it.
|
|
149
|
+
const filtered = prs.filter((p) => p.mergedAt >= sinceISO && p.mergedAt < untilISO);
|
|
150
|
+
// Hitting the fetch cap exactly is the one signal we get that the true count may be higher — the
|
|
151
|
+
// search API gives no "there were more" flag beyond "we returned exactly maxItems results".
|
|
152
|
+
return { repo, available: true, prs: filtered, capped: prs.length === MAX_PRS_FETCHED_PER_REPO };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- gather: shipped/closed epics (git log -p on epic READMEs) ----
|
|
156
|
+
|
|
157
|
+
// Pure — parses `git log --date=iso-strict -p -- 'Roadmap/*/*/README.md'` output for added
|
|
158
|
+
// `+status: shipped|archived` lines, pairing each with the file its diff hunk belongs to (tracked via
|
|
159
|
+
// the preceding `diff --git` header) and the ISO date of the commit that made it (tracked via the
|
|
160
|
+
// preceding `Date:` header). Dedupes to the LAST flip per file in the window (in case a file flipped
|
|
161
|
+
// more than once). The date lets the caller enforce an exact [sinceISO, untilISO) bound in JS — `git
|
|
162
|
+
// log --since/--until` are themselves INCLUSIVE on both ends, not half-open, so relying on them alone
|
|
163
|
+
// (as an earlier version of this function did) could double-count a commit landing exactly on a window
|
|
164
|
+
// boundary second across two consecutive runs.
|
|
165
|
+
export function parseStatusFlipsFromLog(diffText) {
|
|
166
|
+
const flips = new Map(); // file -> {status, date} (insertion order = chronological, oldest→newest from git log)
|
|
167
|
+
let currentFile = null;
|
|
168
|
+
let currentDate = null;
|
|
169
|
+
const dateRe = /^Date:\s+(.+)$/;
|
|
170
|
+
const fileRe = /^diff --git a\/(.+?) b\/.+$/;
|
|
171
|
+
const statusRe = /^\+status:\s*(shipped|archived)\b/;
|
|
172
|
+
for (const line of diffText.split('\n')) {
|
|
173
|
+
const dm = dateRe.exec(line);
|
|
174
|
+
if (dm) {
|
|
175
|
+
currentDate = dm[1].trim();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const fm = fileRe.exec(line);
|
|
179
|
+
if (fm) {
|
|
180
|
+
currentFile = fm[1];
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const sm = statusRe.exec(line);
|
|
184
|
+
if (sm && currentFile) {
|
|
185
|
+
flips.set(currentFile, { status: sm[1], date: currentDate }); // last write wins (chronological — see note below)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [...flips.entries()].map(([file, v]) => ({ file, status: v.status, date: v.date }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Pure — pulls the epic's display name from its README's first `# Epic...`/`# Epic:` heading, else the
|
|
192
|
+
// slug derived from the path.
|
|
193
|
+
export function epicNameFromReadme(markdown, filePath) {
|
|
194
|
+
const m = /^#\s+(.+)$/m.exec(markdown);
|
|
195
|
+
if (m) return m[1].replace(/^Epic:?\s*/i, '').trim();
|
|
196
|
+
const parts = filePath.split('/');
|
|
197
|
+
return parts[parts.length - 2] || filePath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Pure — the exact [sinceISO, untilISO) bound, matching gatherMergedPrs's `p.mergedAt >= sinceISO &&
|
|
201
|
+
// p.mergedAt < untilISO` convention. `git log --since/--until` are themselves INCLUSIVE on both ends, so
|
|
202
|
+
// they're only a coarse pre-filter (passed to git for efficiency) — this is what actually enforces the
|
|
203
|
+
// window and stops a commit landing exactly on a boundary second from double-counting across two
|
|
204
|
+
// consecutive runs.
|
|
205
|
+
export function filterFlipsToWindow(flips, sinceISO, untilISO) {
|
|
206
|
+
const sinceMs = new Date(sinceISO).getTime();
|
|
207
|
+
const untilMs = new Date(untilISO).getTime();
|
|
208
|
+
return flips.filter((f) => {
|
|
209
|
+
const t = new Date(f.date).getTime();
|
|
210
|
+
return t >= sinceMs && t < untilMs;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Returns `{ available: false }` on a git failure (so the caller can distinguish "couldn't read git
|
|
215
|
+
// history" from "genuinely zero epics shipped") — never a bare [] that looks identical to "quiet".
|
|
216
|
+
function gatherShippedEpics(sinceISO, untilISO) {
|
|
217
|
+
// git log defaults to newest-first; -p in that order still gives each flip's LATEST value first per
|
|
218
|
+
// file, so reverse iteration order (oldest first) before building the map so "last write wins" means
|
|
219
|
+
// chronologically last, not textually last in output.
|
|
220
|
+
const r = spawnSync(
|
|
221
|
+
'git',
|
|
222
|
+
[
|
|
223
|
+
'log',
|
|
224
|
+
'--since',
|
|
225
|
+
sinceISO,
|
|
226
|
+
'--until',
|
|
227
|
+
untilISO,
|
|
228
|
+
'--date=iso-strict',
|
|
229
|
+
'-p',
|
|
230
|
+
'--reverse',
|
|
231
|
+
'--',
|
|
232
|
+
'Roadmap/*/*/README.md',
|
|
233
|
+
],
|
|
234
|
+
{ cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }
|
|
235
|
+
);
|
|
236
|
+
if (r.status !== 0) return { available: false, epics: [] };
|
|
237
|
+
const flips = filterFlipsToWindow(parseStatusFlipsFromLog(r.stdout || ''), sinceISO, untilISO);
|
|
238
|
+
const epics = flips.map(({ file, status }) => {
|
|
239
|
+
const abs = join(ROOT, file);
|
|
240
|
+
let name = file;
|
|
241
|
+
let retroDigest = null;
|
|
242
|
+
if (existsSync(abs)) {
|
|
243
|
+
name = epicNameFromReadme(readFileSync(abs, 'utf8'), file);
|
|
244
|
+
}
|
|
245
|
+
const retroPath = join(dirname(abs), 'RETROSPECTIVE.md');
|
|
246
|
+
if (existsSync(retroPath)) {
|
|
247
|
+
retroDigest = extractRetroDigest(readFileSync(retroPath, 'utf8'), RETRO_DIGEST_MAX_CHARS);
|
|
248
|
+
}
|
|
249
|
+
return { file, status, name, retroDigest };
|
|
250
|
+
});
|
|
251
|
+
return { available: true, epics };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Pure — pulls a short excerpt from a RETROSPECTIVE.md's "## What shipped" section (the first
|
|
255
|
+
// paragraph), capped at maxChars. Degrades to null if the section isn't found — the caller then just
|
|
256
|
+
// shows the epic name/link with no digest, rather than failing.
|
|
257
|
+
export function extractRetroDigest(markdown, maxChars) {
|
|
258
|
+
const m = /^##\s+What shipped\s*\n+([\s\S]+?)(?=\n##\s|\n*$)/m.exec(markdown);
|
|
259
|
+
if (!m) return null;
|
|
260
|
+
const firstBlock = m[1]
|
|
261
|
+
.split(/\n\s*\n/)[0]
|
|
262
|
+
.replace(/\s+/g, ' ')
|
|
263
|
+
.trim();
|
|
264
|
+
if (!firstBlock) return null;
|
|
265
|
+
return firstBlock.length > maxChars ? `${firstBlock.slice(0, maxChars).trim()}…` : firstBlock;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- message ----
|
|
269
|
+
// formatPrList/truncateForTelegram now live in scripts/lib/telegram-format.mjs (shared with
|
|
270
|
+
// standup.mjs, which hit the exact same message-length failure mode live).
|
|
271
|
+
|
|
272
|
+
// Pure — builds the Telegram message from already-gathered data. No I/O.
|
|
273
|
+
// `shippedEpics` is `{ available, epics }` — `available: false` (a git-log read failure) must render as
|
|
274
|
+
// "unavailable", never fold into "none this week"/the quiet-week collapse (that's a different fact).
|
|
275
|
+
export function buildMessage({
|
|
276
|
+
sinceISO,
|
|
277
|
+
untilISO,
|
|
278
|
+
repoResults,
|
|
279
|
+
shippedEpics,
|
|
280
|
+
deployRepos = [],
|
|
281
|
+
prose = null,
|
|
282
|
+
}) {
|
|
283
|
+
const since = sinceISO.slice(0, 10);
|
|
284
|
+
const until = untilISO.slice(0, 10);
|
|
285
|
+
const lines = [`<b>Weekly recap · ${since} – ${until}</b>`];
|
|
286
|
+
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push('<b>🚀 Merged PRs</b>');
|
|
289
|
+
let totalPrs = 0;
|
|
290
|
+
for (const r of repoResults) {
|
|
291
|
+
const label = esc(shortRepo(r.repo));
|
|
292
|
+
if (!r.available) {
|
|
293
|
+
lines.push(`${label}: unavailable`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
totalPrs += r.prs.length;
|
|
297
|
+
if (!r.prs.length) {
|
|
298
|
+
lines.push(`${label}: none this week`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const cappedNote = r.capped ? ' ⚠️ hit the fetch cap — count may be incomplete' : '';
|
|
302
|
+
lines.push(`${label} (${r.prs.length}${cappedNote}): ${formatPrList(r.prs, MAX_PRS_SHOWN_PER_REPO)}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Only when the project names its deploy repos — "merging to main IS the deploy" is a per-repo fact,
|
|
306
|
+
// and guessing which repos deploy would print a number that means nothing.
|
|
307
|
+
if (deployRepos.length) {
|
|
308
|
+
lines.push('');
|
|
309
|
+
lines.push('<b>📦 Deploys</b> (merges to main)');
|
|
310
|
+
lines.push(
|
|
311
|
+
deployRepos
|
|
312
|
+
.map(({ label, repo }) => {
|
|
313
|
+
const r = repoResults.find((x) => x.repo === repo);
|
|
314
|
+
return `${esc(label)}: ${r?.available ? r.prs.length : 'unavailable'}`;
|
|
315
|
+
})
|
|
316
|
+
.join(' · ')
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
lines.push('');
|
|
321
|
+
// The count goes in the HEADER, not only in the "…and N more" tail, because the tail is the first
|
|
322
|
+
// thing the 4096-char net eats: a 6-week catch-up renders long enough that truncation lands INSIDE
|
|
323
|
+
// the epic list, taking the tail — and with it the only remaining evidence of how many epics there
|
|
324
|
+
// were — while the header survives. Same shape the PR section has always had: `repo (84): …`.
|
|
325
|
+
const epicCount = shippedEpics.available ? shippedEpics.epics.length : null;
|
|
326
|
+
lines.push(`<b>✅ Shipped / closed epics</b>${epicCount ? ` (${epicCount})` : ''}`);
|
|
327
|
+
if (!shippedEpics.available) {
|
|
328
|
+
lines.push('unavailable (git log read failed)');
|
|
329
|
+
} else if (!shippedEpics.epics.length) {
|
|
330
|
+
lines.push('none this week');
|
|
331
|
+
} else {
|
|
332
|
+
for (const e of shippedEpics.epics.slice(0, MAX_EPICS_SHOWN)) {
|
|
333
|
+
lines.push(`${e.status === 'shipped' ? '✅' : '🗄️'} <b>${esc(e.name)}</b>`);
|
|
334
|
+
if (e.retroDigest) lines.push(esc(e.retroDigest));
|
|
335
|
+
}
|
|
336
|
+
// The count above the fold stays TRUE even when the list is cut — a reader must not read a capped
|
|
337
|
+
// list as the whole week. Same contract as formatPrList's "…and N more".
|
|
338
|
+
const rest = shippedEpics.epics.length - MAX_EPICS_SHOWN;
|
|
339
|
+
if (rest > 0) lines.push(`…and ${rest} more`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Only collapse to the quiet-week one-liner when every signal actually reported in — an unavailable
|
|
343
|
+
// repo or an unavailable epic read must never be silently swallowed into the upbeat "nothing happened"
|
|
344
|
+
// framing (those are different facts a reader needs distinguished).
|
|
345
|
+
const allAvailable = repoResults.every((r) => r.available) && shippedEpics.available;
|
|
346
|
+
if (allAvailable && totalPrs === 0 && !shippedEpics.epics.length) {
|
|
347
|
+
const quiet = `<b>Weekly recap · ${since} – ${until}</b>\n🌙 Quiet week — nothing merged or shipped since the last recap.`;
|
|
348
|
+
return truncateForTelegram(spliceProse(quiet, prose), TELEGRAM_MAX_CHARS);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The prose is spliced in HERE, before the cap — not by the caller afterwards. It used to be
|
|
352
|
+
// `buildMessage(...).replace(/\n/, prose)` in main(), which meant the safety net was applied one layer
|
|
353
|
+
// BELOW the message that actually got sent: buildMessage returned exactly <=4096, then main() grew it
|
|
354
|
+
// by the length of the prose. That is why the 2026-08-24 run died on "message too long" despite a
|
|
355
|
+
// correct, unit-tested truncator — the pure core was right and the assembly it did not own was wrong.
|
|
356
|
+
return truncateForTelegram(spliceProse(lines.join('\n'), prose), TELEGRAM_MAX_CHARS);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Prose leads, the existing tallies follow (the standup makes the same shape decision). Prose going
|
|
360
|
+
// FIRST also decides what survives the cap: the hand-written recap is the point of the message, so a
|
|
361
|
+
// truncation eats the tallies, never the prose.
|
|
362
|
+
function spliceProse(base, prose) {
|
|
363
|
+
return prose ? base.replace(/\n/, `\n\n${prose}\n`) : base;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- Telegram (same shape standup.mjs reimplements standalone) ----
|
|
367
|
+
|
|
368
|
+
async function sendTelegram(chatId, text) {
|
|
369
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
370
|
+
if (!token) die('TELEGRAM_BOT_TOKEN is not set — export it before running weekly-recap.mjs.');
|
|
371
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
headers: { 'Content-Type': 'application/json' },
|
|
374
|
+
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true }),
|
|
375
|
+
signal: AbortSignal.timeout(10000),
|
|
376
|
+
});
|
|
377
|
+
let body = {};
|
|
378
|
+
try {
|
|
379
|
+
body = await res.json();
|
|
380
|
+
} catch {
|
|
381
|
+
/* non-JSON error body */
|
|
382
|
+
}
|
|
383
|
+
if (!res.ok) die(`Telegram sendMessage failed: ${res.status} ${JSON.stringify(body).slice(0, 300)}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- log persistence (scripts/lib/log-branch.mjs — a dedicated claude/-prefixed branch) ----
|
|
387
|
+
|
|
388
|
+
function appendRunAndPush(entry) {
|
|
389
|
+
const ok = appendLineToBranch({
|
|
390
|
+
cwd: ROOT,
|
|
391
|
+
branch: LOG_BRANCH,
|
|
392
|
+
path: LOG_BRANCH_PATH,
|
|
393
|
+
line: `${JSON.stringify(entry)}\n`,
|
|
394
|
+
message: `chore(weekly-recap): log ${new Date().toISOString().slice(0, 10)}`,
|
|
395
|
+
});
|
|
396
|
+
if (!ok) {
|
|
397
|
+
console.error(
|
|
398
|
+
`weekly-recap: writing the updated log to ${LOG_BRANCH} failed — the next run (esp. a fresh ` +
|
|
399
|
+
`routine session) won't see it and will re-derive its window from a stale/missing log.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- main ----
|
|
405
|
+
|
|
406
|
+
async function main() {
|
|
407
|
+
let config;
|
|
408
|
+
try {
|
|
409
|
+
config = loadReportingConfig({ root: ROOT });
|
|
410
|
+
} catch (e) {
|
|
411
|
+
if (e instanceof ReportingConfigError) die(e.message);
|
|
412
|
+
throw e;
|
|
413
|
+
}
|
|
414
|
+
ensureGh();
|
|
415
|
+
|
|
416
|
+
const now = new Date();
|
|
417
|
+
const lastRun = loadLastRun();
|
|
418
|
+
const { sinceISO, untilISO } = computeWindow(lastRun, now, SINCE_OVERRIDE, UNTIL_OVERRIDE);
|
|
419
|
+
|
|
420
|
+
const repoResults = config.repos.map((repo) => gatherMergedPrs(repo, sinceISO, untilISO));
|
|
421
|
+
const shippedEpics = gatherShippedEpics(sinceISO, untilISO);
|
|
422
|
+
|
|
423
|
+
// ── Phase 1: --brief ──────────────────────────────────────────────────────────────────────
|
|
424
|
+
if (BRIEF) {
|
|
425
|
+
const windowLabel = `the week of ${sinceISO.slice(0, 10)} to ${untilISO.slice(0, 10)}`;
|
|
426
|
+
const prCount = repoResults.reduce((n, r) => n + (r.available ? r.prs.length : 0), 0);
|
|
427
|
+
const epics = shippedEpics.available ? shippedEpics.epics : [];
|
|
428
|
+
|
|
429
|
+
// D7: nothing shipped AND nothing merged is the only genuinely quiet week.
|
|
430
|
+
if (!epics.length && prCount === 0) {
|
|
431
|
+
writeSync(1, `${buildQuietBrief(windowLabel)}\n`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Shipped/closed epics lead — the highest-altitude product input we own, written in product
|
|
436
|
+
// language by the people who did the work, with their retro digests attached.
|
|
437
|
+
const roadmapDeltas = epics.map(
|
|
438
|
+
(e) =>
|
|
439
|
+
`Epic "${e.name}" moved to ${e.status}${e.retroDigest ? ` — from its retrospective: ${e.retroDigest}` : ''}`
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const pack = buildEvidencePack({
|
|
443
|
+
windowLabel,
|
|
444
|
+
roadmapDeltas,
|
|
445
|
+
owed: loadOwedLedger(join(ROOT, 'Roadmap/00-ideas/OWED-LEDGER.json')),
|
|
446
|
+
repoSignals: [
|
|
447
|
+
`${prCount} pull request(s) merged across ${config.repos.length} repositor${config.repos.length === 1 ? 'y' : 'ies'} this week.`,
|
|
448
|
+
],
|
|
449
|
+
liveFlags: { available: false, flags: [] },
|
|
450
|
+
});
|
|
451
|
+
writeSync(1, `${buildBrief({ scriptsDir: __dirname, surface: 'weekly', pack })}\n`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Phase 3: --post --prose-file ──────────────────────────────────────────────────────────
|
|
456
|
+
// `--post` requires an explicit mode — see standup.mjs. A quiet WEEK matters more here than a quiet
|
|
457
|
+
// night: this script tracks a WINDOW, so if the routine stops without posting, `windowEnd` never
|
|
458
|
+
// advances and the next run re-counts the same period. Cross-review caught exactly that.
|
|
459
|
+
if (POST && !PROSE_FILE && !QUIET) {
|
|
460
|
+
die(
|
|
461
|
+
'--post needs either --prose-file <path> (a guarded prose post) or --quiet (the one-line\n' +
|
|
462
|
+
' quiet-week message, which also ADVANCES the window log). Refusing an unguarded send.'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let prose = null;
|
|
467
|
+
if (POST && PROSE_FILE) {
|
|
468
|
+
const draft = readFileSync(resolve(ROOT, PROSE_FILE), 'utf8').trim();
|
|
469
|
+
// Derived from the window, not hardcoded — see standup.mjs.
|
|
470
|
+
const subjects = repoResults.flatMap((r) => (r.available ? r.prs.map((p) => p.title || '') : []));
|
|
471
|
+
const areas = shippedEpics.available && shippedEpics.epics.length ? ['customer-facing pages'] : [];
|
|
472
|
+
const evidence = deriveEvidenceFlags({ subjects, areas, liveFlags: [], maxWords: WEEKLY_MAX_WORDS });
|
|
473
|
+
// jev-semantic-guards S3.2: the semantic families are judged by Jev per jev.config.json → rails.prose.mode;
|
|
474
|
+
// `checkProse` is the fallback and, with the rail off, the whole of it. The decider is on the result.
|
|
475
|
+
const verdict = await judgeProse(draft, {
|
|
476
|
+
...evidence,
|
|
477
|
+
extraBannedToolNames: config.prose.extraBannedToolNames,
|
|
478
|
+
});
|
|
479
|
+
const found = verdict.findings.map((f) => f.code).join(', ');
|
|
480
|
+
process.stderr.write(`prose guard: decided by ${verdict.decider} (${verdict.mode}) — ${found || 'clean'}\n`);
|
|
481
|
+
if (!verdict.ok && !FORCE_POST) {
|
|
482
|
+
process.stderr.write(`${findingsToRevisionNote(verdict.findings)}\n`);
|
|
483
|
+
process.exit(2);
|
|
484
|
+
}
|
|
485
|
+
prose = verdict.ok
|
|
486
|
+
? draft
|
|
487
|
+
: `${draft}\n\n<i>⚠ flagged draft — ${verdict.findings.map((f) => f.code).join(', ')}</i>`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const message = buildMessage({
|
|
491
|
+
sinceISO,
|
|
492
|
+
untilISO,
|
|
493
|
+
repoResults,
|
|
494
|
+
shippedEpics,
|
|
495
|
+
deployRepos: config.deployRepos,
|
|
496
|
+
prose,
|
|
497
|
+
});
|
|
498
|
+
console.log(message.replace(/<\/?[^>]+>/g, ''));
|
|
499
|
+
|
|
500
|
+
if (!DRY_RUN) {
|
|
501
|
+
const chatId = chatIdFor(config, 'weekly');
|
|
502
|
+
if (!chatId) {
|
|
503
|
+
die(
|
|
504
|
+
'No Telegram chat id configured — set "telegram.chatIds.weekly" or "telegram.chatId" in ' +
|
|
505
|
+
'reporting.config.json, or export TELEGRAM_CHAT_ID.'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
await sendTelegram(chatId, message);
|
|
509
|
+
|
|
510
|
+
appendRunAndPush({
|
|
511
|
+
ts: now.toISOString(),
|
|
512
|
+
windowStart: sinceISO,
|
|
513
|
+
windowEnd: untilISO,
|
|
514
|
+
prCount: repoResults.reduce((n, r) => n + (r.available ? r.prs.length : 0), 0),
|
|
515
|
+
shippedCount: shippedEpics.available ? shippedEpics.epics.length : null,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
521
|
+
if (isMain) {
|
|
522
|
+
main().catch((e) => {
|
|
523
|
+
console.error(e.message || e);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
});
|
|
526
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@golden-frijoles/kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gf-kit": "bin.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin.mjs",
|
|
11
|
+
"dist/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/golden-frijoles/skills.git",
|
|
21
|
+
"directory": "kit"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/golden-frijoles/skills#readme",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"golden-frijoles",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"agent-skills",
|
|
28
|
+
"roadmap"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|