@golden-frijoles/kit 0.7.0 → 0.9.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.
@@ -0,0 +1,95 @@
1
+ <!--
2
+ weekly-recap.prompt.md — Routine weekly-recap (the weekly Telegram exec recap/retro), the Claude-
3
+ Routines prompt.
4
+
5
+ This is the prompt for a WEEKLY Claude Code *Routine* (cloud session, research preview) on the
6
+ monorepo-root repo (<root-repo>), running as the product owner — the longer-horizon complement to
7
+ the nightly `ops-nightly` standup. Added by Sprint 3 of the ops-routines-reporting epic. Mirrors
8
+ ops-nightly's shape (a load-bearing Telegram post is the routine's actual output, not an optional
9
+ failure-ping) but on a weekly cadence, and mirrors Routine C's precedent (a dedicated weekly routine,
10
+ not a day-of-week-gated step folded into a nightly one).
11
+
12
+ Reuse, don't rebuild (the `weekly-recap` skill comes from the `golden-frijoles` plugin, golden-frijoles
13
+ marketplace — invoke by name, not a repo-local
14
+ `skills/<name>/SKILL.md` path, which no longer exists in this repo):
15
+ - `weekly-recap` skill → scripts/weekly-recap.mjs (the gathering, message-building, and the
16
+ actual Telegram send + weekly-recaps.log commit — this routine just invokes it and reports back)
17
+ - gh CLI (every configured repo), git log -p on epic READMEs (status: SSOT), the same configured repo list
18
+ scripts/standup.mjs already uses.
19
+
20
+ Stand-up + guardrails: scripts/routines/README.md. Decision: made in the origin project — see this directory's README preamble. (skill conventions) and 09-platform-infra/ops-routines-reporting/
21
+ sprint-3.md (this story's scope).
22
+
23
+ The HTML comment above is not part of the prompt; a routine runs everything below the first `---`.
24
+ -->
25
+
26
+ ---
27
+
28
+ You are a weekly **weekly-recap** Claude Code Routine on the root repo (`<root-repo>`),
29
+ running as the product owner. Your job is one step, then stop: post the week's executive recap to Telegram.
30
+ Everything you do is **advisory/observability only** — read-only aggregation plus one Telegram post and
31
+ one log-commit; you never merge, approve, block, or touch any repo's code.
32
+
33
+ ## The one step — `weekly-recap`, written by YOU in three phases
34
+
35
+ The recap is no longer a tally: **you write it, as the CPO persona, and a mechanical guard checks
36
+ your draft before it posts.** Same rail as the nightly standup, one altitude higher.
37
+
38
+ **You must write the prose yourself, in this session. Do NOT call `devin`, `agy`, `codex`, or
39
+ `prose-draft.mjs`** — none of them is authenticated here, and reaching for one produces no report at
40
+ all rather than an obvious failure. This is the single most important line in this file.
41
+
42
+ Config/secrets first: the committed `reporting.config.json` (repos and signals) plus the chat id — from the
43
+ `TELEGRAM_CHAT_ID` env var in this unattended session (a gitignored `reporting.config.local.json` exists
44
+ only on a local machine, never here). If `TELEGRAM_CHAT_ID` is unset (and nothing is configured), that's a
45
+ hard stop — use the failure ping below; never `AskUserQuestion`, no interactive human is present.
46
+ `TELEGRAM_BOT_TOKEN` must be set.
47
+
48
+ **Phase 1 — get the brief.**
49
+ ```
50
+ node scripts/weekly-recap.mjs --brief
51
+ ```
52
+ It prints your persona, the accumulated lessons, and the week's evidence — **shipped and closed epics
53
+ first, with excerpts from their retrospectives**, then merged-PR volume as corroboration. It writes
54
+ nothing and sends nothing. **If it returns the "Nothing to report" brief**, do NOT write prose — run
55
+ `node scripts/weekly-recap.mjs --post --quiet` instead. It posts the one-line quiet-week message
56
+ **and advances the window log**; stopping without it leaves `windowEnd` unmoved and the next run
57
+ double-counts. Do not manufacture content.
58
+
59
+ **Phase 2 — write the prose.** Answer one question: *what is now possible that was not possible on
60
+ Monday, and what is still owed?* Group by **theme**, never by commit or by repository — a list of
61
+ merges is not a report, and the word budget is not permission to enumerate. Name the decision that
62
+ shaped the week if there was one. State what is owed; a weekly that quietly omits a known gap is the
63
+ one unforgivable error here. Save your draft to a temp file.
64
+
65
+ **Phase 3 — guard, then post.**
66
+ ```
67
+ node scripts/weekly-recap.mjs --post --prose-file <your-file>
68
+ ```
69
+ - **Exit 0** → posted.
70
+ - **Non-zero exit** → it prints a numbered revision note. Rewrite the draft in full against every
71
+ point, then re-run once with `--force-post`. A flagged-but-posted report beats a missing one, and
72
+ the label tells a human it did not converge.
73
+ - One revision, then post. Do not loop further.
74
+
75
+ Report the result afterwards (merged-PR / shipped-epic counts, whether the guard passed clean).
76
+
77
+ ## Nothing else
78
+ No PR, no comment, no code change of your own — the Telegram post (plus the `scripts/weekly-recaps.log`
79
+ commit `weekly-recap.mjs` makes itself) is the routine's entire output. **Advisory only** — not a gate,
80
+ not a merge, not a status check. If the week was genuinely quiet (nothing merged, nothing shipped),
81
+ that's still a fully successful run — the script itself collapses to a one-line "quiet week" message; do
82
+ not manufacture extra content to pad it out.
83
+
84
+ ## If the run can't complete (optional failure ping)
85
+ A healthy run reaches the product owner via the recap's Telegram post — no extra notice needed. But a run that
86
+ **fails to complete** (missing `TELEGRAM_BOT_TOKEN`, missing/unconfigured chat id, `gh` unauthenticated,
87
+ the script erroring out, the log push failing) would otherwise be silent. So, **only on a blocking
88
+ failure**, if **both** `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in the environment,
89
+ best-effort POST a one-line alert:
90
+ `curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" -d chat_id="$TELEGRAM_CHAT_ID" --data-urlencode text="⚠️ Routine weekly-recap failed: <one-line reason>"`
91
+ If either var is unset (or `api.telegram.org` isn't allow-listed), skip it silently — never block on it,
92
+ and **never** ping after a run that completed successfully, even a fully quiet one.
93
+
94
+ Note: `TELEGRAM_CHAT_ID` here is both the failure-ping target and what `weekly-recap.mjs` posts the recap
95
+ to — one variable, both call sites.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ // slack-notify.mjs — the CI/CD Slack ping, structured the same way as scripts/telegram-notify.mjs.
3
+ //
4
+ // Used by .github/workflows/notify-telegram.yml's push-notification and deploy-notification jobs,
5
+ // alongside (not instead of) the existing Telegram calls:
6
+ // node scripts/slack-notify.mjs --icon 📦 --sha "$GITHUB_SHA" --subject "$COMMIT_MSG" \
7
+ // --meta "by @$AUTHOR" --url "$DIFF_URL" --link-label "view diff"
8
+ //
9
+ // ── Why Incoming Webhook and not a bot token + chat.postMessage ────────────────────────────────
10
+ // Same reasoning this repo already applied to Telegram (no VERCEL_API_TOKEN when a push-based
11
+ // signal existed instead — see notify-telegram.yml's header): one fewer credential to mint, store
12
+ // and rotate. An Incoming Webhook URL is itself the channel-scoped credential — there is no
13
+ // separate bot token, channel ID, or OAuth scope to manage for this feature. If a future need
14
+ // requires posting to a channel chosen at runtime (rather than the one fixed at webhook-creation
15
+ // time), that is the point to move to `chat.postMessage` with a bot token — not before.
16
+ //
17
+ // ── Response handling is NOT the same shape as Telegram's — this is the one real gotcha ───────
18
+ // Telegram's sendMessage returns JSON ({ok: bool, ...}) regardless of outcome. Slack's Incoming
19
+ // Webhook endpoint does NOT: on success it returns the literal string "ok" (200, `text/plain`); on
20
+ // failure it returns a short plain-text error token (e.g. "invalid_payload", "channel_not_found",
21
+ // "no_service") with a non-200 status. Parsing the body as JSON here would throw on every single
22
+ // call, success or failure. So `send()` reads the body as text and treats HTTP status as the
23
+ // primary success signal — see Slack's own webhook error-handling docs if this ever needs
24
+ // re-verifying against a live change.
25
+ //
26
+ // ── Escaping is mrkdwn, not HTML, but the substitution table is identical ──────────────────────
27
+ // See scripts/lib/slack-text.mjs's header for why escapeMrkdwn and Telegram's escapeHtml do the
28
+ // same three replacements. Links use Slack's `<url|label>` syntax rather than `<a href>`; inline
29
+ // code uses single backticks rather than `<code>`.
30
+ //
31
+ // House style carried over unchanged: secrets only from GitHub Secrets, a 10s request timeout, and
32
+ // a repo with the secret unset gets a clean skip (not a failure) — see main() below.
33
+
34
+ import { escapeMrkdwn, escapeToFit, SLACK_LIMIT } from './lib/slack-text.mjs';
35
+ import { die, need } from './lib/cross-agent-cli.mjs';
36
+ import { resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ /**
40
+ * Assemble the Incoming Webhook payload.
41
+ *
42
+ * The subject is the only field given a length budget, same rule as Telegram's buildNotification:
43
+ * the sha, status, icon and link are structural, and truncating them to make room for a runaway
44
+ * commit message would discard exactly the parts a reader needs.
45
+ *
46
+ * Pure, and exported, so a test exercises the real assembler rather than a paraphrase of it.
47
+ */
48
+ export function buildNotification({ repo, sha, icon, subject, meta, status, url, linkLabel }) {
49
+ const shortSha = String(sha ?? '').slice(0, 7);
50
+
51
+ // Chrome first, measured rather than estimated — same reasoning as Telegram's version: a
52
+ // hardcoded guess here is how a "safe" cap stops being safe once a line is added below.
53
+ const head = `${escapeMrkdwn(repo)} · ${icon} · \`${escapeMrkdwn(shortSha)}\``;
54
+ const tail = `<${escapeMrkdwn(url)}|${escapeMrkdwn(linkLabel)}>`;
55
+ const metaLine = meta ? escapeMrkdwn(meta) : null;
56
+ // `status` is our own literal ("✅ READY"), never external input — not escaped, same as Telegram's
57
+ // version, so the emoji and casing survive.
58
+ const statusLine = status || null;
59
+
60
+ const chrome = [head, metaLine, statusLine, tail].filter(Boolean).join('\n').length + 1;
61
+
62
+ // First line only: a commit body belongs behind the link, not in a notification.
63
+ const firstLine = String(subject ?? '').split('\n')[0];
64
+ const safeSubject = escapeToFit(firstLine, Math.max(1, SLACK_LIMIT - chrome - 16));
65
+
66
+ const lines = [head, safeSubject];
67
+ if (metaLine) lines.push(metaLine);
68
+ if (statusLine) lines.push(statusLine);
69
+ lines.push(tail);
70
+
71
+ return {
72
+ text: lines.join('\n'),
73
+ // Belt-and-suspenders alongside Telegram's disable_web_page_preview: without this a commit or
74
+ // deployment link can unfurl into a big preview card that buries the next message.
75
+ unfurl_links: false,
76
+ unfurl_media: false,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * POST it. Returns { ok, status, body } — never throws.
82
+ *
83
+ * Slack's webhook response is PLAIN TEXT ("ok" on success, a short error token on failure), not
84
+ * JSON like Telegram's — see this file's header. An accepted webhook is the conjunction Slack
85
+ * documents: a 2xx response and the literal plain-text body `ok`.
86
+ */
87
+ export async function send(webhookUrl, payload, fetchImpl = fetch) {
88
+ try {
89
+ const res = await fetchImpl(webhookUrl, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(payload),
93
+ signal: AbortSignal.timeout(10_000),
94
+ });
95
+ const body = await res.text().catch(() => '');
96
+ return { ok: res.ok && body.trim() === 'ok', status: res.status, body };
97
+ } catch (err) {
98
+ return { ok: false, status: 0, body: `request failed: ${err?.message ?? err}` };
99
+ }
100
+ }
101
+
102
+ async function main() {
103
+ const args = process.argv.slice(2);
104
+ const opt = {};
105
+ for (let i = 0; i < args.length; i++) {
106
+ const k = args[i];
107
+ if (!k.startsWith('--')) die(`unexpected argument ${k}`);
108
+ opt[k.slice(2)] = need(args[++i], k);
109
+ }
110
+
111
+ const webhookUrl = process.env.SLACK_WEBHOOK_URL?.trim();
112
+ if (!webhookUrl) {
113
+ // Same clean-skip stance as telegram-notify.mjs: a repo without the secret configured is a
114
+ // configuration state, not a failure.
115
+ process.stderr.write(
116
+ 'SLACK_WEBHOOK_URL not set — skipping cleanly.\n' +
117
+ ' Set it as a GitHub Secret on this repo to turn Slack notifications on.\n'
118
+ );
119
+ return;
120
+ }
121
+
122
+ const payload = buildNotification({
123
+ repo: opt.repo ?? 'golden-beans',
124
+ sha: opt.sha ?? '',
125
+ icon: opt.icon ?? '📦',
126
+ subject: opt.subject ?? '',
127
+ meta: opt.meta ?? '',
128
+ status: opt.status ?? '',
129
+ url: opt.url ?? '',
130
+ linkLabel: opt['link-label'] ?? 'view',
131
+ });
132
+
133
+ if (payload.text.length > SLACK_LIMIT) {
134
+ // Should be unreachable — escapeToFit bounds the only unbounded field. Checked anyway, same
135
+ // stance as telegram-notify.mjs.
136
+ die(`assembled text is ${payload.text.length} chars, over the ${SLACK_LIMIT}-char budget`);
137
+ }
138
+
139
+ const result = await send(webhookUrl, payload);
140
+ if (result.ok) {
141
+ process.stderr.write(`✓ ping delivered (${payload.text.length} chars)\n`);
142
+ return;
143
+ }
144
+
145
+ // Loud, same policy as Telegram: an observer workflow with a rejected message and no visible
146
+ // failure is a rail with no monitoring at all.
147
+ process.stderr.write(
148
+ `::error title=Slack ping NOT delivered::status=${result.status} body=${result.body}\n`
149
+ );
150
+ process.exitCode = 1;
151
+ }
152
+
153
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
154
+ if (isMain) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
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
5
  "type": "module",
6
6
  "bin": {