@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.
Files changed (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. 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, '&lt;')
100
+ .replace(/>/g, '&gt;');
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
- {"name":"@golden-frijoles/kit","version":"0.0.0","description":"Bootstrap placeholder. Install @golden-frijoles/kit@>=0.2.0.","license":"Apache-2.0","repository":{"type":"git","url":"git+https://github.com/golden-frijoles/skills.git","directory":"kit"}}
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
+ }