@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,469 @@
1
+ // report-registry.mjs — the report scripts' half of reporthub-as-notion Sprint 1: uploads a report
2
+ // payload to the GCS registry provisioned by the project's bucket-provisioning script (Story 1.1) and
3
+ // returns a short `/r/<slug>` link served by the project's doc-viewer resolver (Story 1.2). On
4
+ // ANY failure — no credentials, no `gcloud`, a rejected upload, an unreachable bucket — this degrades to
5
+ // the caller's already-computed URL-hash link (LEARNINGS soft-mode pattern: standup.mjs/weekly-recap.mjs
6
+ // already do this for a missing/wiped delta-log baseline; this is the same discipline applied to a new
7
+ // failure mode). Callers keep building the URL-hash link first (scripts/lib/pmo-templates.mjs's
8
+ // buildDocViewerUrl) — this module only ever *upgrades* that link, never replaces the guaranteed fallback.
9
+ //
10
+ // The resolver URL and bucket are the PROJECT's (reporting.config.json → artifacts.registry). Unset, the
11
+ // registry is simply off: buildReportLink returns the fallback with reason `registry-not-configured` and
12
+ // uploads nothing. There is deliberately no default bucket — a default is one project's storage.
13
+ //
14
+ // Slug -> object path convention (matches the project's bucket-provisioning script's lifecycle rule and
15
+ // the fork's /r/<slug> resolver EXACTLY — a change here needs the same-wave change called out in
16
+ // the project's registry-invariants test):
17
+ // daily-story-YYYY-MM-DD-<hash6> -> daily/daily-story-YYYY-MM-DD-<hash6>.md (90d TTL)
18
+ // pmo-weekly-YYYY-MM-DD -> packets/pmo-weekly-YYYY-MM-DD.md (kept forever)
19
+ // pmo-monthly-YYYY-MM-DD -> packets/pmo-monthly-YYYY-MM-DD.md (kept forever)
20
+ // pmo-sheet-YYYY-MM-DD -> packets/pmo-sheet-YYYY-MM-DD.md (kept forever)
21
+ // Any other slug that doesn't start with "daily-" also lands under packets/ — the daily/ prefix is the
22
+ // only thing the lifecycle rule keys off, so anything not explicitly daily must default to "kept forever".
23
+ //
24
+ // Zero new deps: the credentialed upload path signs a service-account JSON key's JWT with node:crypto,
25
+ // exchanges it for an OAuth token, and PUTs straight to the storage.googleapis.com JSON API — no
26
+ // google-cloud/storage package. A `gcloud storage cp` fallback covers local/interactive runs that already
27
+ // have Application Default Credentials but no service-account key file at hand.
28
+
29
+ import { createSign } from 'node:crypto';
30
+ import { readFileSync } from 'node:fs';
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ export const DAILY_PREFIX = 'daily/';
34
+ export const PACKETS_PREFIX = 'packets/';
35
+ // reporthub-as-notion S2.1: a THIRD prefix for artifacts that need to be repeatedly overwritten in
36
+ // place at a stable, well-known key (a live JSON/markdown snapshot the hub reads on every page load) —
37
+ // unlike daily/ and packets/ objects, which are one-shot immutable artifacts protected by
38
+ // `if-generation-match: 0` (see uploadViaRest/uploadViaGcloud below), live/ objects are written with
39
+ // `allowOverwrite: true` and are expected to change on every publish run. Not covered by the bucket's
40
+ // 90d lifecycle rule (the project's bucket-provisioning script only expires daily/ — a live/ object is a
41
+ // single small rolling snapshot, not an accumulating log, so "kept forever" is fine and matches
42
+ // packets/'s default).
43
+ export const LIVE_PREFIX = 'live/';
44
+
45
+ const GCS_UPLOAD_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_write';
46
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
47
+
48
+ // ---------------------------------------------------------------------------------------------------
49
+ // Pure — slug generation, path mapping, URL building, the fallback decision. No I/O; fully unit-testable
50
+ // without a live bucket/credentials (LEARNINGS: keep the auth/upload plumbing separate from the logic a
51
+ // regression test should pin).
52
+ // ---------------------------------------------------------------------------------------------------
53
+
54
+ function pad2(n) {
55
+ return String(n).padStart(2, '0');
56
+ }
57
+
58
+ // UTC date, not local — routine runs execute in an unknown timezone; a UTC-stable slug means the same
59
+ // calendar instant always produces the same daily/packet key regardless of where the script executes.
60
+ export function dateStamp(date = new Date()) {
61
+ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
62
+ }
63
+
64
+ // A short, deterministic, collision-safe suffix derived from the payload's own content (FNV-1a, 32-bit,
65
+ // hex — no crypto module needed for a non-adversarial collision-avoidance hash). Two different reports
66
+ // generated on the same UTC day get different suffixes; the SAME report content re-uploaded on the same
67
+ // day reproduces the SAME slug, which makes an accidental double-run idempotent (overwrite, not a stray
68
+ // duplicate object) instead of minting a fresh throwaway link each retry.
69
+ export function contentSuffix(content) {
70
+ let hash = 0x811c9dc5;
71
+ const s = String(content ?? '');
72
+ for (let i = 0; i < s.length; i += 1) {
73
+ hash ^= s.charCodeAt(i);
74
+ hash = Math.imul(hash, 0x01000193);
75
+ }
76
+ return (hash >>> 0).toString(16).padStart(8, '0').slice(0, 6);
77
+ }
78
+
79
+ // Slug builders — one per artifact kind the report scripts emit today. `daily-story` is the ONLY kind
80
+ // that carries a collision suffix + lands under daily/; every packet kind is a stable one-per-day slug
81
+ // under packets/ (a same-day re-run intentionally overwrites the same object — packets are the
82
+ // "kept forever" canonical link for that day, not a throwaway).
83
+ export function dailyStorySlug({ date = new Date(), markdown = '' } = {}) {
84
+ return `daily-story-${dateStamp(date)}-${contentSuffix(markdown)}`;
85
+ }
86
+
87
+ export function pmoWeeklySlug({ date = new Date() } = {}) {
88
+ return `pmo-weekly-${dateStamp(date)}`;
89
+ }
90
+
91
+ export function pmoMonthlySlug({ date = new Date() } = {}) {
92
+ return `pmo-monthly-${dateStamp(date)}`;
93
+ }
94
+
95
+ export function pmoSheetSlug({ date = new Date() } = {}) {
96
+ return `pmo-sheet-${dateStamp(date)}`;
97
+ }
98
+
99
+ const SLUG_BUILDERS = {
100
+ standup: dailyStorySlug,
101
+ 'daily-story': dailyStorySlug,
102
+ weekly: pmoWeeklySlug,
103
+ monthly: pmoMonthlySlug,
104
+ sheet: pmoSheetSlug,
105
+ };
106
+
107
+ // Pure — lowercases and collapses anything outside [a-z0-9-] to a single '-' (leading/trailing dashes
108
+ // trimmed). Only used for the UNRECOGNIZED-name fallback slug below: a caller-supplied artifact `name`
109
+ // is developer-controlled today, but a future artifact kind (or a typo) could carry spaces, punctuation,
110
+ // or mixed case, and a slug is a URL path segment / GCS object key — never build one from unsanitized
111
+ // interpolation. Falls back to 'report' if sanitizing empties the string entirely.
112
+ export function sanitizeSlugPart(value) {
113
+ const cleaned = String(value ?? '')
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9-]+/g, '-')
116
+ .replace(/-+/g, '-')
117
+ .replace(/^-|-$/g, '');
118
+ return cleaned || 'report';
119
+ }
120
+
121
+ // Dispatches by the report scripts' own artifact `name` (standup.mjs's 'standup', pmo-report.mjs's
122
+ // 'weekly'/'monthly'/'sheet') to the matching slug builder. An unrecognized name still gets a safe,
123
+ // unique-enough slug (packet-shaped, content-suffixed, sanitized) rather than throwing — a new artifact
124
+ // kind added later degrades to "works, not optimally named" instead of crashing the report script.
125
+ export function slugForArtifact({ name, date = new Date(), markdown = '' }) {
126
+ const builder = SLUG_BUILDERS[name];
127
+ if (builder) return builder({ date, markdown });
128
+ return `${sanitizeSlugPart(name)}-${dateStamp(date)}-${contentSuffix(markdown)}`;
129
+ }
130
+
131
+ // The one place the daily/-vs-packets/ split is decided — matches
132
+ // the project's bucket-provisioning script's `DAILY_PREFIX="daily/"` lifecycle rule exactly.
133
+ export function objectPathForSlug(slug) {
134
+ return slug.startsWith('daily-') ? `${DAILY_PREFIX}${slug}.md` : `${PACKETS_PREFIX}${slug}.md`;
135
+ }
136
+
137
+ // live/<key>.<ext> — the object path for an overwrite-allowed live artifact (Story 2.1/2.2). `key` is
138
+ // sanitized the same way an unrecognized artifact name is (see sanitizeSlugPart below) because it's
139
+ // still a GCS object-key/URL path segment; a caller-supplied key should never be interpolated raw.
140
+ export function liveObjectPath(key, ext = 'json') {
141
+ return `${LIVE_PREFIX}${sanitizeSlugPart(key)}.${ext}`;
142
+ }
143
+
144
+ export function registryUrl({ slug, baseUrl }) {
145
+ return `${baseUrl}/r/${slug}`;
146
+ }
147
+
148
+ export function resolveBucket(env = process.env, configured = null) {
149
+ return env.REPORT_REGISTRY_BUCKET || configured || null;
150
+ }
151
+
152
+ // Pure — the fallback decision itself, isolated so it's independently testable from the I/O that
153
+ // produces `ok`/`reason`. "Any failure" means exactly that: no special-casing of WHY the upload failed.
154
+ export function shouldFallbackToUrlHash(uploadResult) {
155
+ return !uploadResult?.ok;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------------------------------
159
+ // Impure — credential resolution + the actual upload. Two paths, tried in order:
160
+ // 1. Service-account JSON key (routine pattern — an env-var-provided key, per LEARNINGS' env-var
161
+ // fallback discipline for anything a routine needs unattended): signs + exchanges its own JWT,
162
+ // zero new deps, then PUTs the payload straight to the JSON API.
163
+ // 2. `gcloud storage cp` (local/interactive pattern — Application Default Credentials already on the
164
+ // machine, no key file needed).
165
+ // Neither available/working -> soft failure, never throws; the caller always gets a `{ ok, reason }`.
166
+ // ---------------------------------------------------------------------------------------------------
167
+
168
+ function base64url(input) {
169
+ return Buffer.from(input).toString('base64url');
170
+ }
171
+
172
+ function loadServiceAccountKey(env) {
173
+ if (env.GOOGLE_APPLICATION_CREDENTIALS_JSON) {
174
+ try {
175
+ return JSON.parse(env.GOOGLE_APPLICATION_CREDENTIALS_JSON);
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ if (env.GOOGLE_APPLICATION_CREDENTIALS) {
181
+ try {
182
+ return JSON.parse(readFileSync(env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8'));
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // Signs a self-issued JWT for a service-account key (RS256, node:crypto only) and exchanges it for an
191
+ // OAuth access token via the standard JWT-bearer grant. Returns null on any failure (malformed key,
192
+ // network error, non-2xx response) — never throws, so a routine with no key configured just falls
193
+ // through to the gcloud path or the URL-hash fallback.
194
+ export async function getAccessTokenFromServiceAccountKey(
195
+ key,
196
+ { fetchImpl = fetch, now = () => Date.now() } = {}
197
+ ) {
198
+ if (!key?.client_email || !key?.private_key) return null;
199
+ const iat = Math.floor(now() / 1000);
200
+ const header = { alg: 'RS256', typ: 'JWT' };
201
+ const claim = {
202
+ iss: key.client_email,
203
+ scope: GCS_UPLOAD_SCOPE,
204
+ aud: TOKEN_URL,
205
+ iat,
206
+ exp: iat + 3600,
207
+ };
208
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`;
209
+ let signature;
210
+ try {
211
+ signature = createSign('RSA-SHA256').update(signingInput).sign(key.private_key);
212
+ } catch {
213
+ return null;
214
+ }
215
+ const assertion = `${signingInput}.${Buffer.from(signature).toString('base64url')}`;
216
+
217
+ try {
218
+ const res = await fetchImpl(TOKEN_URL, {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
221
+ body: new URLSearchParams({
222
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
223
+ assertion,
224
+ }),
225
+ signal: AbortSignal.timeout(10000),
226
+ });
227
+ if (!res.ok) return null;
228
+ const body = await res.json();
229
+ return body.access_token || null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ // Precondition-failure detector shared by both upload paths — a 412 (REST) or gcloud's equivalent
236
+ // stderr means "an object already exists at this generation-0 precondition", i.e. this exact slug is
237
+ // already taken. That's treated as SUCCESS (not overwritten, but the /r/<slug> link already resolves to
238
+ // *something* — idempotent-safe for a same-day re-run with unchanged content, and for a genuine
239
+ // same-day content drift on a packet slug it's a deliberate "never silently replace a public link"
240
+ // trade-off, not a bug).
241
+ function isPreconditionFailure(text) {
242
+ return /\b412\b|precondition/i.test(String(text ?? ''));
243
+ }
244
+
245
+ export async function uploadViaRest({
246
+ bucket,
247
+ objectPath,
248
+ markdown,
249
+ token,
250
+ fetchImpl = fetch,
251
+ contentType = 'text/markdown; charset=utf-8',
252
+ // Story 2.1: live/ objects are meant to be overwritten on every publish run — set true to skip the
253
+ // generation-0 precondition entirely. Every existing caller (daily/packets uploads) leaves this false
254
+ // and keeps the original never-overwrite behavior unchanged.
255
+ allowOverwrite = false,
256
+ }) {
257
+ try {
258
+ const headers = {
259
+ 'Content-Type': contentType,
260
+ Authorization: `Bearer ${token}`,
261
+ };
262
+ // Overwrite protection: generation 0 means "only succeed if no object exists at this name yet" — an
263
+ // existing /r/<slug> object can never be silently replaced by a later run. Skipped for live/ objects,
264
+ // which are expected to change on every publish.
265
+ if (!allowOverwrite) headers['x-goog-if-generation-match'] = '0';
266
+ const res = await fetchImpl(
267
+ `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o` +
268
+ `?uploadType=media&name=${encodeURIComponent(objectPath)}`,
269
+ {
270
+ method: 'POST',
271
+ headers,
272
+ body: markdown,
273
+ signal: AbortSignal.timeout(15000),
274
+ }
275
+ );
276
+ if (res.ok) return { ok: true };
277
+ if (res.status === 412) return { ok: true, reason: 'already-exists' };
278
+ return { ok: false, reason: `rest-upload-http-${res.status}` };
279
+ } catch (err) {
280
+ return { ok: false, reason: `rest-upload-error: ${err.message || err}` };
281
+ }
282
+ }
283
+
284
+ function gcloudAvailable(spawnSyncImpl) {
285
+ const r = spawnSyncImpl('gcloud', ['--version'], { encoding: 'utf8' });
286
+ return r.status === 0;
287
+ }
288
+
289
+ export function uploadViaGcloud({
290
+ bucket,
291
+ objectPath,
292
+ markdown,
293
+ spawnSyncImpl = spawnSync,
294
+ contentType = 'text/markdown; charset=utf-8',
295
+ allowOverwrite = false,
296
+ }) {
297
+ if (!gcloudAvailable(spawnSyncImpl)) return { ok: false, reason: 'gcloud-not-available' };
298
+ // `cp -` (stdin) has no filename to sniff a content-type from — pin it explicitly so the object serves
299
+ // as `text/markdown` (matching the REST path's Content-Type) instead of defaulting to
300
+ // application/octet-stream, which some renderers refuse to treat as text.
301
+ // --if-generation-match=0: same overwrite protection as the REST path's x-goog-if-generation-match
302
+ // header — `gcloud storage cp` supports this flag directly (confirmed: `gcloud storage cp --help`).
303
+ // Skipped when allowOverwrite is true (live/ objects — see uploadViaRest's comment).
304
+ const args = ['storage', 'cp', '-', `gs://${bucket}/${objectPath}`, `--content-type=${contentType}`];
305
+ if (!allowOverwrite) args.push('--if-generation-match=0');
306
+ const r = spawnSyncImpl('gcloud', args, { input: markdown, encoding: 'utf8' });
307
+ if (r.status === 0) return { ok: true };
308
+ const stderr = (r.stderr || '').trim();
309
+ if (isPreconditionFailure(stderr)) return { ok: true, reason: 'already-exists' };
310
+ return { ok: false, reason: `gcloud-cp-failed: ${stderr.slice(0, 300)}` };
311
+ }
312
+
313
+ // Orchestrates the two upload paths. Tries the credentialed REST path first (the routine/unattended
314
+ // pattern — an env-var key is the only thing that works with no interactive gcloud session at all); falls
315
+ // back to `gcloud storage cp` (the local/interactive pattern — ADC already on the machine). Returns
316
+ // `{ ok: false, reason: 'no-credentials' }` when neither is usable, never throws.
317
+ export async function uploadReportPayload({
318
+ bucket,
319
+ slug,
320
+ markdown,
321
+ env = process.env,
322
+ fetchImpl = fetch,
323
+ spawnSyncImpl = spawnSync,
324
+ // Story 2.1: override the slug->path derivation (used by publishLiveArtifact below to target live/
325
+ // instead of daily//packets/) and the content-type (JSON payloads, not just markdown). Both default to
326
+ // the original packet/daily behavior, so every existing caller is unaffected.
327
+ objectPath = objectPathForSlug(slug),
328
+ contentType = 'text/markdown; charset=utf-8',
329
+ allowOverwrite = false,
330
+ }) {
331
+ const key = loadServiceAccountKey(env);
332
+ if (key) {
333
+ const token = await getAccessTokenFromServiceAccountKey(key, { fetchImpl });
334
+ if (token) {
335
+ const result = await uploadViaRest({
336
+ bucket,
337
+ objectPath,
338
+ markdown,
339
+ token,
340
+ fetchImpl,
341
+ contentType,
342
+ allowOverwrite,
343
+ });
344
+ if (result.ok) return result;
345
+ // A key was configured but the upload itself failed (bad IAM binding, wrong bucket, etc.) — still
346
+ // worth trying the gcloud path in case ADC on this machine covers it, before giving up.
347
+ }
348
+ }
349
+
350
+ if (gcloudAvailable(spawnSyncImpl)) {
351
+ return uploadViaGcloud({ bucket, objectPath, markdown, spawnSyncImpl, contentType, allowOverwrite });
352
+ }
353
+
354
+ return { ok: false, reason: 'no-credentials' };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------------------------------
358
+ // Story 2.1/2.2 — publish a LIVE artifact (a well-known, repeatedly-overwritten key under live/), for
359
+ // the hub's auto-refreshed roadmap/sprint views and PMO chart views. This is deliberately a SEPARATE
360
+ // call site from buildReportLink/upgradeArtifactLinks above: those upgrade a Telegram artifact's
361
+ // fallback URL to a one-shot, overwrite-PROTECTED packet link; this publishes a rolling snapshot at a
362
+ // STABLE address the hub always fetches from — no fallback-URL concept, just "did the publish
363
+ // succeed?". Never throws (same soft-mode contract as uploadReportPayload); a routine calling this
364
+ // degrades gracefully — the hub's client-side fetch (public/reports.js) falls back to its last-known
365
+ // bundled snapshot on any failure, so a failed publish is a staleness issue, not an outage.
366
+ // ---------------------------------------------------------------------------------------------------
367
+ export async function publishLiveArtifact({
368
+ bucket = resolveBucket(),
369
+ key,
370
+ content,
371
+ ext = 'json',
372
+ contentType = ext === 'json' ? 'application/json; charset=utf-8' : 'text/markdown; charset=utf-8',
373
+ env = process.env,
374
+ uploader = uploadReportPayload,
375
+ }) {
376
+ const objectPath = liveObjectPath(key, ext);
377
+ if (!bucket) return { ok: false, objectPath, reason: 'registry-not-configured' };
378
+ try {
379
+ const result = await uploader({
380
+ bucket,
381
+ objectPath,
382
+ markdown: content,
383
+ env,
384
+ contentType,
385
+ allowOverwrite: true,
386
+ });
387
+ if (result?.ok) return { ok: true, objectPath };
388
+ return { ok: false, objectPath, reason: result?.reason || 'unknown' };
389
+ } catch (err) {
390
+ return { ok: false, objectPath, reason: `uploader-threw: ${(err && err.message) || err}` };
391
+ }
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------------------------------
395
+ // The one call site the report scripts use: try to upload + upgrade to a short /r/<slug> link; on ANY
396
+ // failure — including an uploader that REJECTS rather than resolving `{ ok: false }` — keep the
397
+ // caller's already-built URL-hash link and note the fallback on stderr (never throws, never blocks the
398
+ // script from still printing/sending its report).
399
+ // ---------------------------------------------------------------------------------------------------
400
+
401
+ export async function buildReportLink({
402
+ name,
403
+ markdown,
404
+ fallbackUrl,
405
+ date = new Date(),
406
+ bucket = resolveBucket(),
407
+ baseUrl = null,
408
+ env = process.env,
409
+ uploader = uploadReportPayload,
410
+ logError = (msg) => console.error(msg),
411
+ logInfo = (msg) => console.error(msg),
412
+ // Dry-run: never write to the registry. Still computes + logs the slug/link this run WOULD have used
413
+ // (so `--dry-run` output stays informative) but skips calling `uploader` entirely — dry-run must be a
414
+ // real no-op against the live bucket, not just skip Telegram/log persistence.
415
+ dryRun = false,
416
+ } = {}) {
417
+ const slug = slugForArtifact({ name, date, markdown });
418
+ if (!bucket || !baseUrl) {
419
+ return { url: fallbackUrl, slug, usedRegistry: false, reason: 'registry-not-configured' };
420
+ }
421
+ if (dryRun) {
422
+ logInfo(
423
+ `report-registry: dry run — would upload "${name}" as slug ${slug} ` +
424
+ `(${registryUrl({ slug, baseUrl })}); no write performed.`
425
+ );
426
+ return { url: fallbackUrl, slug, usedRegistry: false, reason: 'dry-run', dryRun: true };
427
+ }
428
+
429
+ let uploadResult;
430
+ try {
431
+ uploadResult = await uploader({ bucket, slug, markdown, env });
432
+ } catch (err) {
433
+ // An uploader that REJECTS (vs. resolving `{ ok: false, reason }`) must degrade exactly the same way
434
+ // — the real uploadReportPayload never rejects (every internal branch is try/caught), but this
435
+ // boundary's contract ("never throws") has to hold for ANY uploader implementation passed in, not
436
+ // just the well-behaved default.
437
+ uploadResult = { ok: false, reason: `uploader-threw: ${(err && err.message) || err}` };
438
+ }
439
+ if (!shouldFallbackToUrlHash(uploadResult)) {
440
+ return { url: registryUrl({ slug, baseUrl }), slug, usedRegistry: true };
441
+ }
442
+ logError(
443
+ `report-registry: upload of "${name}" (slug ${slug}) to gs://${bucket} failed ` +
444
+ `(${uploadResult.reason || 'unknown reason'}) — falling back to the URL-hash link.`
445
+ );
446
+ return { url: fallbackUrl, slug, usedRegistry: false, reason: uploadResult.reason || 'unknown' };
447
+ }
448
+
449
+ // Convenience for the report scripts: mutates each `{ name, markdown, url }` artifact's `url` in place
450
+ // (keeping `url` as the fallback if the upgrade fails) and returns the same array, so a caller can do
451
+ // `artifacts = await upgradeArtifactLinks(artifacts, { dryRun })` right after building them with the
452
+ // existing URL-hash builder (scripts/lib/pmo-templates.mjs's buildDocViewerUrl /
453
+ // scripts/lib/standup-deck.mjs's buildStandupArtifacts) with no other call-site changes. `options` is
454
+ // forwarded to buildReportLink as-is — pass `dryRun: true` to skip every write for this run (see
455
+ // buildReportLink's `dryRun` param above).
456
+ export async function upgradeArtifactLinks(artifacts, options = {}) {
457
+ for (const artifact of artifacts) {
458
+ const result = await buildReportLink({
459
+ name: artifact.name,
460
+ markdown: artifact.markdown,
461
+ fallbackUrl: artifact.url,
462
+ ...options,
463
+ });
464
+ artifact.url = result.url;
465
+ artifact.slug = result.slug;
466
+ artifact.usedRegistry = result.usedRegistry;
467
+ }
468
+ return artifacts;
469
+ }