@golden-frijoles/kit 0.0.0 → 0.3.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/init.mjs +132 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +204 -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/skeleton/Roadmap/00-ideas/README.md +85 -0
- package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
- package/dist/skeleton/Roadmap/README.md +67 -0
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -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
package/dist/standup.mjs
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// standup.mjs — gathers overnight signals across the project's repos and posts a DELTA-ONLY Telegram standup.
|
|
3
|
+
//
|
|
4
|
+
// Project values (repos, smoke workflow, stale-preview age, live-flag source, chat, doc viewer) come
|
|
5
|
+
// from reporting.config.json via scripts/lib/reporting-config.mjs. An absent optional section turns
|
|
6
|
+
// that signal OFF rather than borrowing a default. Ported into the dobby-foundation template by
|
|
7
|
+
// plugin-audit-and-extraction S1; the gathering and delta logic are unchanged from the origin.
|
|
8
|
+
//
|
|
9
|
+
// Signals: opened/merged PRs + CI status + merge-conflict state (gh, every configured repo), the latest
|
|
10
|
+
// run of the configured browser-smoke workflow (`smoke`), BUILD-ORDER.md drift
|
|
11
|
+
// (`node scripts/build-order.mjs --check`), open-PR state, and the stale-preview count
|
|
12
|
+
// (`node scripts/vercel-prune-previews.mjs --project <vercelProject> --age <stalePreviewAgeDays>`, dry-run — never `--apply`). The CI-red and
|
|
13
|
+
// conflict signals are this standup's OWN independent read — taken after babysit-pr has had a chance
|
|
14
|
+
// to act (it runs earlier in the same ops-nightly routine), so a "still red" line reflects state
|
|
15
|
+
// post-retry, not pre-retry.
|
|
16
|
+
//
|
|
17
|
+
// Delta-only: keeps scripts/standups.log (JSONL, one snapshot per run) and diffs the current gather
|
|
18
|
+
// against the LAST logged snapshot. Nothing changed → posts a one-line "quiet night" message instead of
|
|
19
|
+
// re-dumping everything. After a successful Telegram post, commits + pushes the updated log directly to
|
|
20
|
+
// `main` (path-scoped) so the next run — including a fresh nightly-routine session — has yesterday's
|
|
21
|
+
// state to diff against.
|
|
22
|
+
//
|
|
23
|
+
// Usage:
|
|
24
|
+
// node scripts/standup.mjs # gather, diff, post to Telegram, commit+push the log
|
|
25
|
+
// node scripts/standup.mjs --dry-run # gather + print the message only — fully read-only (does NOT
|
|
26
|
+
// # append to standups.log, does not touch Telegram or git, and
|
|
27
|
+
// # does NOT write to the report registry — S1.3,
|
|
28
|
+
// # scripts/lib/report-registry.mjs's upgradeArtifactLinks is
|
|
29
|
+
// # called with dryRun: true, which logs the would-be /r/<slug>
|
|
30
|
+
// # link but performs no upload; the printed message still shows
|
|
31
|
+
// # the existing URL-hash fallback link in that case).
|
|
32
|
+
//
|
|
33
|
+
// Reuse, don't rebuild: ensureGh()/die() (scripts/lib/cross-agent-cli.mjs), build-order.mjs --check,
|
|
34
|
+
// vercel-prune-previews.mjs dry-run, and the api.telegram.org sendMessage shape (reimplemented standalone —
|
|
35
|
+
// this script has no access to the app's node_modules/TS build). Zero npm deps — Node >=20 (global fetch, spawnSync).
|
|
36
|
+
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { readFileSync, writeSync } from 'node:fs';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { dirname, join, resolve } from 'node:path';
|
|
41
|
+
import { ensureGh, die } from './lib/cross-agent-cli.mjs';
|
|
42
|
+
import { loadReportingConfig, chatIdFor, shortRepo, ReportingConfigError } from './lib/reporting-config.mjs';
|
|
43
|
+
import { listPulls, getPullMergeability, getStatusRollup } from './lib/gh-rest.mjs';
|
|
44
|
+
import { formatPrList, telegramHtmlToConsoleText } from './lib/telegram-format.mjs';
|
|
45
|
+
import { readLogFromBranch, appendLineToBranch } from './lib/log-branch.mjs';
|
|
46
|
+
import { appendStandupArtifactsToMessage, buildStandupArtifacts } from './lib/standup-deck.mjs';
|
|
47
|
+
import { upgradeArtifactLinks } from './lib/report-registry.mjs';
|
|
48
|
+
import { findingsToRevisionNote, judgeProse } from './lib/prose-guard.mjs';
|
|
49
|
+
import {
|
|
50
|
+
buildEvidencePack,
|
|
51
|
+
buildQuietBrief,
|
|
52
|
+
buildBrief,
|
|
53
|
+
deriveEvidenceFlags,
|
|
54
|
+
loadOwedLedger,
|
|
55
|
+
} from './lib/prose-brief.mjs';
|
|
56
|
+
import { parseStatusFlipsFromLog } from './weekly-recap.mjs';
|
|
57
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
58
|
+
|
|
59
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const ROOT = projectRoot(); // D2
|
|
61
|
+
|
|
62
|
+
// The delta log lives on a dedicated `claude/`-prefixed branch, not committed to `main` — a routine's
|
|
63
|
+
// DEFAULT push scope already covers `claude/`-prefixed branches, so this needs no extra permission (see
|
|
64
|
+
// scripts/lib/log-branch.mjs for why: the "Allow unrestricted branch pushes" toggle failed to save live,
|
|
65
|
+
// 2026-07-02/03).
|
|
66
|
+
const LOG_BRANCH = 'claude/standup-log';
|
|
67
|
+
// Flat filename (no directory) — git mktree builds a single-level tree, and this branch holds nothing
|
|
68
|
+
// else, so there's no reason to nest it under scripts/.
|
|
69
|
+
const LOG_BRANCH_PATH = 'standups.log';
|
|
70
|
+
|
|
71
|
+
const DRY_RUN = process.argv.includes('--dry-run');
|
|
72
|
+
|
|
73
|
+
// ── Two-phase prose modes (exec-prose-rail S2; README D2) ────────────────────────────────────
|
|
74
|
+
// `--brief` prints the persona + lessons + evidence pack and EXITS — writes nothing, sends nothing,
|
|
75
|
+
// touches no git. `--post --prose-file <p>` guards a routine-written draft and posts it.
|
|
76
|
+
const BRIEF = process.argv.includes('--brief');
|
|
77
|
+
const POST = process.argv.includes('--post');
|
|
78
|
+
const FORCE_POST = process.argv.includes('--force-post');
|
|
79
|
+
function argAfter(flag) {
|
|
80
|
+
const i = process.argv.indexOf(flag);
|
|
81
|
+
return i !== -1 ? process.argv[i + 1] : null;
|
|
82
|
+
}
|
|
83
|
+
const PROSE_FILE = argAfter('--prose-file');
|
|
84
|
+
const QUIET = process.argv.includes('--quiet');
|
|
85
|
+
|
|
86
|
+
// The standup is a chat message read on a phone. This is the length budget the guard enforces.
|
|
87
|
+
const STANDUP_MAX_WORDS = 90;
|
|
88
|
+
|
|
89
|
+
// Written by epic B story 3 when it lands. Absent is normal and must degrade cleanly — neither epic
|
|
90
|
+
// may hard-depend on the other's merge order.
|
|
91
|
+
const OWED_LEDGER_PATH = join(ROOT, 'Roadmap/00-ideas/OWED-LEDGER.json');
|
|
92
|
+
|
|
93
|
+
const MAX_PRS_SHOWN_PER_REPO = 12; // caps a busy-night delta listing before it dominates the message
|
|
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
|
+
// ---- gather: gh-backed signals (each degrades to `available: false` on any gh error — one repo being
|
|
104
|
+
// unreachable/unauthed never kills the whole run) ----
|
|
105
|
+
|
|
106
|
+
function ghJson(args) {
|
|
107
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
108
|
+
if (r.status !== 0) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(r.stdout || 'null');
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// REST-only (scripts/lib/gh-rest.mjs) — `gh pr list --json` hits GraphQL internally, which is blocked in
|
|
117
|
+
// at least one live routine sandbox (confirmed 2026-07-02). The list endpoint alone doesn't carry
|
|
118
|
+
// mergeable/check-status, so those are one extra pair of REST calls per OPEN PR only (bounded — typically
|
|
119
|
+
// a handful, never the full 50-item history list).
|
|
120
|
+
function gatherRepoPrs(repo) {
|
|
121
|
+
const prs = listPulls({ repo, state: 'all', perPage: 50 });
|
|
122
|
+
if (prs === null) return { repo, available: false };
|
|
123
|
+
|
|
124
|
+
const open = prs.filter((p) => p.state === 'OPEN');
|
|
125
|
+
const merged = prs.filter((p) => p.state === 'MERGED');
|
|
126
|
+
|
|
127
|
+
const failingOpenNumbers = [];
|
|
128
|
+
const conflictingOpenNumbers = [];
|
|
129
|
+
for (const p of open) {
|
|
130
|
+
const mergeable = getPullMergeability({ repo, number: p.number });
|
|
131
|
+
if (mergeable === 'CONFLICTING') conflictingOpenNumbers.push(p.number);
|
|
132
|
+
if (p.headSha) {
|
|
133
|
+
const rollup = getStatusRollup({ repo, sha: p.headSha }) || [];
|
|
134
|
+
const failing = rollup.some(
|
|
135
|
+
(c) => c.conclusion === 'FAILURE' || c.conclusion === 'ERROR' || c.state === 'FAILURE'
|
|
136
|
+
);
|
|
137
|
+
if (failing) failingOpenNumbers.push(p.number);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
repo,
|
|
143
|
+
available: true,
|
|
144
|
+
openNumbers: open.map((p) => p.number),
|
|
145
|
+
mergedNumbers: merged.map((p) => p.number),
|
|
146
|
+
failingOpenNumbers,
|
|
147
|
+
// Surfaced by babysit-pr's own advisory comment too — this is the standup's independent read of the
|
|
148
|
+
// same fact, taken AFTER babysit-pr has had a chance to act (it runs earlier in the ops-nightly routine).
|
|
149
|
+
conflictingOpenNumbers,
|
|
150
|
+
// small lookup for rendering human-readable delta lines
|
|
151
|
+
byNumber: Object.fromEntries(prs.map((p) => [p.number, { title: p.title, url: p.url }])),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function gatherSmoke(smokeConfig) {
|
|
156
|
+
if (!smokeConfig) return { available: false };
|
|
157
|
+
const runs = ghJson([
|
|
158
|
+
'run',
|
|
159
|
+
'list',
|
|
160
|
+
'--repo',
|
|
161
|
+
smokeConfig.repo,
|
|
162
|
+
'--workflow',
|
|
163
|
+
smokeConfig.workflow,
|
|
164
|
+
'-L',
|
|
165
|
+
'1',
|
|
166
|
+
'--json',
|
|
167
|
+
'conclusion,status,createdAt,url',
|
|
168
|
+
]);
|
|
169
|
+
if (runs === null || !runs.length) return { available: false };
|
|
170
|
+
const r = runs[0];
|
|
171
|
+
return { available: true, conclusion: r.conclusion, status: r.status, createdAt: r.createdAt, url: r.url };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- gather: local scripts ----
|
|
175
|
+
|
|
176
|
+
function gatherBuildOrderDrift() {
|
|
177
|
+
const r = spawnSync(process.execPath, [join(__dirname, 'build-order.mjs'), '--check'], { cwd: ROOT, encoding: 'utf8' });
|
|
178
|
+
return { drifted: r.status !== 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function gatherStalePreviews(ageDays, project) {
|
|
182
|
+
if (!ageDays || !project) return { available: false };
|
|
183
|
+
const r = spawnSync(
|
|
184
|
+
process.execPath,
|
|
185
|
+
[join(__dirname, 'vercel-prune-previews.mjs'), '--project', project, '--age', String(ageDays)],
|
|
186
|
+
{
|
|
187
|
+
cwd: ROOT,
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
if (r.status !== 0 && !r.stdout) return { available: false };
|
|
192
|
+
const m = (r.stdout || '').match(/Preview deployments to remove[^:]*:\s*(\d+)/);
|
|
193
|
+
return { available: m != null, count: m ? Number(m[1]) : null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- gather: roadmap deltas (D4 — the product-language input that LEADS the evidence pack) ----
|
|
197
|
+
|
|
198
|
+
// Epic README `status:` frontmatter flips + sprint `**Status:**` line changes in the window. Reuses
|
|
199
|
+
// weekly-recap.mjs's `parseStatusFlipsFromLog` rather than writing a second parser for the same
|
|
200
|
+
// format (the epic-status SSOT is that frontmatter key — see scripts/build-order.mjs).
|
|
201
|
+
export function gatherRoadmapDeltas(sinceExpr = '1 day ago', deps = {}) {
|
|
202
|
+
const {
|
|
203
|
+
run = (args) => spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }),
|
|
204
|
+
} = deps;
|
|
205
|
+
|
|
206
|
+
const epicLog = run([
|
|
207
|
+
'log',
|
|
208
|
+
`--since=${sinceExpr}`,
|
|
209
|
+
'--date=iso-strict',
|
|
210
|
+
'-p',
|
|
211
|
+
'--',
|
|
212
|
+
'Roadmap/*/*/README.md',
|
|
213
|
+
]);
|
|
214
|
+
const flips = epicLog.status === 0 ? parseStatusFlipsFromLog(epicLog.stdout || '') : [];
|
|
215
|
+
|
|
216
|
+
const deltas = flips.map((f) => {
|
|
217
|
+
const slug = f.file.split('/').slice(-2)[0];
|
|
218
|
+
return `Epic "${slug}" moved to ${f.status}.`;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Sprint docs that changed at all in the window — named in product terms, not as file paths.
|
|
222
|
+
const sprintLog = run([
|
|
223
|
+
'log',
|
|
224
|
+
`--since=${sinceExpr}`,
|
|
225
|
+
'--name-only',
|
|
226
|
+
'--format=',
|
|
227
|
+
'--',
|
|
228
|
+
'Roadmap/*/*/sprint-*.md',
|
|
229
|
+
]);
|
|
230
|
+
if (sprintLog.status === 0) {
|
|
231
|
+
const touched = [
|
|
232
|
+
...new Set(
|
|
233
|
+
(sprintLog.stdout || '')
|
|
234
|
+
.split('\n')
|
|
235
|
+
.map((l) => l.trim())
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
),
|
|
238
|
+
];
|
|
239
|
+
for (const f of touched.slice(0, 8)) {
|
|
240
|
+
const parts = f.split('/');
|
|
241
|
+
const slug = parts[parts.length - 2];
|
|
242
|
+
const sprint = (parts[parts.length - 1] || '').replace(/\.md$/, '');
|
|
243
|
+
deltas.push(`Sprint doc updated: ${slug} / ${sprint}.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return deltas;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Flags currently ON — real input for the guard's `flag-state-claim` rule (README D6).
|
|
250
|
+
//
|
|
251
|
+
// Source is the project's own flag store, read by the argv in reporting.config.json → liveFlags.command
|
|
252
|
+
// (run from liveFlags.cwd). It must print one flag key per line; a `key` header line is ignored, so a
|
|
253
|
+
// CSV query result works as-is. No command configured → unavailable, never "none are on".
|
|
254
|
+
//
|
|
255
|
+
// Returns `{ available, flags }`, and the distinction is load-bearing: "no flags are on" and "I could
|
|
256
|
+
// not check" are different facts, and collapsing them to an empty array would let the brief tell the
|
|
257
|
+
// writer nothing is live when the truth is unknown. Unavailable is the expected case in a routine
|
|
258
|
+
// sandbox with no database credentials, so it must read as unknown, not as a negative finding.
|
|
259
|
+
export function gatherLiveFlags(liveFlagsConfig, deps = {}) {
|
|
260
|
+
const {
|
|
261
|
+
run = () =>
|
|
262
|
+
spawnSync(liveFlagsConfig.command[0], liveFlagsConfig.command.slice(1), {
|
|
263
|
+
cwd: join(ROOT, liveFlagsConfig.cwd || '.'),
|
|
264
|
+
encoding: 'utf8',
|
|
265
|
+
timeout: 60000,
|
|
266
|
+
}),
|
|
267
|
+
} = deps;
|
|
268
|
+
if (!liveFlagsConfig && !deps.run) return { available: false, flags: [] };
|
|
269
|
+
try {
|
|
270
|
+
const r = run();
|
|
271
|
+
if (!r || r.status !== 0) return { available: false, flags: [] };
|
|
272
|
+
const rows = (r.stdout || '')
|
|
273
|
+
.split('\n')
|
|
274
|
+
.map((l) => l.trim())
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.filter((l) => l.toLowerCase() !== 'key');
|
|
277
|
+
return { available: true, flags: rows };
|
|
278
|
+
} catch {
|
|
279
|
+
return { available: false, flags: [] };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Commit subjects + touched areas for the window — the inputs `deriveEvidenceFlags` needs to decide
|
|
284
|
+
// whether a fix claim or a customer mention is legitimate. Degrades to empty (the closed, safe
|
|
285
|
+
// default) rather than failing the post.
|
|
286
|
+
export function gatherWindowFacts(sinceExpr = '1 day ago', deps = {}) {
|
|
287
|
+
const {
|
|
288
|
+
run = (args) => spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }),
|
|
289
|
+
} = deps;
|
|
290
|
+
const log = run(['log', '--no-merges', `--since=${sinceExpr}`, '--format=%s']);
|
|
291
|
+
const subjects =
|
|
292
|
+
log.status === 0
|
|
293
|
+
? (log.stdout || '')
|
|
294
|
+
.split('\n')
|
|
295
|
+
.map((l) => l.trim())
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
: [];
|
|
298
|
+
const names = run(['log', '--no-merges', `--since=${sinceExpr}`, '--name-only', '--format=']);
|
|
299
|
+
const paths =
|
|
300
|
+
names.status === 0
|
|
301
|
+
? (names.stdout || '')
|
|
302
|
+
.split('\n')
|
|
303
|
+
.map((l) => l.trim())
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
: [];
|
|
306
|
+
const areas = [
|
|
307
|
+
...new Set(
|
|
308
|
+
paths.map((p) => {
|
|
309
|
+
if (p.startsWith('Roadmap/')) return 'roadmap docs';
|
|
310
|
+
if (p.startsWith('scripts/')) return 'internal tooling';
|
|
311
|
+
if (p.includes('/app/')) return 'customer-facing pages';
|
|
312
|
+
if (p.includes('/e2e/')) return 'test suite';
|
|
313
|
+
return p.split('/')[0] || 'other';
|
|
314
|
+
})
|
|
315
|
+
),
|
|
316
|
+
];
|
|
317
|
+
return { subjects, areas };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- delta log (JSONL, one line per run — lives on a dedicated branch, see below) ----
|
|
321
|
+
|
|
322
|
+
function loadLastRun() {
|
|
323
|
+
const content = readLogFromBranch({ cwd: ROOT, branch: LOG_BRANCH, path: LOG_BRANCH_PATH });
|
|
324
|
+
if (!content) return null;
|
|
325
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
326
|
+
if (!lines.length) return null;
|
|
327
|
+
try {
|
|
328
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildSnapshot({ repoSignals, smoke, buildOrder, previews }) {
|
|
335
|
+
return {
|
|
336
|
+
ts: new Date().toISOString(),
|
|
337
|
+
repos: Object.fromEntries(
|
|
338
|
+
repoSignals.map((r) => [
|
|
339
|
+
r.repo,
|
|
340
|
+
r.available
|
|
341
|
+
? {
|
|
342
|
+
openNumbers: r.openNumbers,
|
|
343
|
+
mergedNumbers: r.mergedNumbers,
|
|
344
|
+
failingOpenNumbers: r.failingOpenNumbers,
|
|
345
|
+
conflictingOpenNumbers: r.conflictingOpenNumbers,
|
|
346
|
+
}
|
|
347
|
+
: null,
|
|
348
|
+
])
|
|
349
|
+
),
|
|
350
|
+
smoke: smoke.available
|
|
351
|
+
? { conclusion: smoke.conclusion, status: smoke.status, createdAt: smoke.createdAt }
|
|
352
|
+
: null,
|
|
353
|
+
buildOrderDrifted: buildOrder.drifted,
|
|
354
|
+
stalePreviews: previews.available ? previews.count : null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Pure — the unit that matters for "run twice, second run says no change". No I/O.
|
|
359
|
+
//
|
|
360
|
+
// A missing/wiped `prevRepo` baseline is NOT the same as "everything happened last night" — enumerating
|
|
361
|
+
// gh's entire recent-PR window as "new" overflows Telegram's 4096-char limit and crashes before ever
|
|
362
|
+
// posting or persisting a log (confirmed live, 2026-07-02/03: standups.log had never been committed, so
|
|
363
|
+
// every run re-derived a from-scratch "merged: <100+ PR titles>" dump and died). On a missing baseline,
|
|
364
|
+
// emit ONE bounded summary line per repo (counts only, no per-PR title enumeration) instead.
|
|
365
|
+
export function diffSnapshots(prev, cur, repoSignals, { stalePreviewAgeDays = null } = {}) {
|
|
366
|
+
const lines = [];
|
|
367
|
+
const byNumberByRepo = Object.fromEntries(repoSignals.map((r) => [r.repo, r.byNumber || {}]));
|
|
368
|
+
|
|
369
|
+
for (const [repo, curRepo] of Object.entries(cur.repos)) {
|
|
370
|
+
if (!curRepo) continue; // this repo was unavailable this run
|
|
371
|
+
const label = esc(shortRepo(repo));
|
|
372
|
+
const prevRepo = prev?.repos?.[repo] || null;
|
|
373
|
+
const byNumber = byNumberByRepo[repo] || {};
|
|
374
|
+
|
|
375
|
+
if (!prevRepo) {
|
|
376
|
+
lines.push(
|
|
377
|
+
`📋 <b>${label}</b> baseline established (${curRepo.openNumbers.length} open, ` +
|
|
378
|
+
`${curRepo.mergedNumbers.length} recently merged) — deltas start next run.`
|
|
379
|
+
);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const prevMerged = new Set(prevRepo.mergedNumbers || []);
|
|
384
|
+
const newMerged = curRepo.mergedNumbers.filter((n) => !prevMerged.has(n));
|
|
385
|
+
if (newMerged.length) {
|
|
386
|
+
const titled = newMerged.map((n) => ({ number: n, title: byNumber[n]?.title || '' }));
|
|
387
|
+
lines.push(`✅ <b>${label}</b> merged: ${formatPrList(titled, MAX_PRS_SHOWN_PER_REPO)}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const prevOpenSet = new Set(prevRepo.openNumbers || []);
|
|
391
|
+
const newOpened = curRepo.openNumbers.filter((n) => !prevOpenSet.has(n));
|
|
392
|
+
if (newOpened.length) {
|
|
393
|
+
const titled = newOpened.map((n) => ({ number: n, title: byNumber[n]?.title || '' }));
|
|
394
|
+
lines.push(`🆕 <b>${label}</b> opened: ${formatPrList(titled, MAX_PRS_SHOWN_PER_REPO)}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const prevFailing = new Set(prevRepo.failingOpenNumbers || []);
|
|
398
|
+
const newFailing = curRepo.failingOpenNumbers.filter((n) => !prevFailing.has(n));
|
|
399
|
+
if (newFailing.length)
|
|
400
|
+
lines.push(`🔴 <b>${label}</b> CI red on open PR: ${newFailing.map((n) => `#${n}`).join(', ')}`);
|
|
401
|
+
|
|
402
|
+
const prevConflicting = new Set(prevRepo.conflictingOpenNumbers || []);
|
|
403
|
+
const newConflicting = (curRepo.conflictingOpenNumbers || []).filter((n) => !prevConflicting.has(n));
|
|
404
|
+
if (newConflicting.length)
|
|
405
|
+
lines.push(
|
|
406
|
+
`⚠️ <b>${label}</b> merge conflict on open PR: ${newConflicting.map((n) => `#${n}`).join(', ')}`
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (prevRepo.openNumbers?.length !== curRepo.openNumbers.length) {
|
|
410
|
+
lines.push(`📋 <b>${label}</b> open PRs: ${curRepo.openNumbers.length}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const prevSmokeKey = prev?.smoke ? `${prev.smoke.conclusion}|${prev.smoke.createdAt}` : null;
|
|
415
|
+
const curSmokeKey = cur.smoke ? `${cur.smoke.conclusion}|${cur.smoke.createdAt}` : null;
|
|
416
|
+
if (prevSmokeKey !== curSmokeKey) {
|
|
417
|
+
lines.push(
|
|
418
|
+
cur.smoke
|
|
419
|
+
? `🧪 Browser smoke: ${cur.smoke.conclusion || cur.smoke.status || 'unknown'}`
|
|
420
|
+
: '🧪 Browser smoke: unavailable'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!prev || prev.buildOrderDrifted !== cur.buildOrderDrifted) {
|
|
425
|
+
lines.push(
|
|
426
|
+
cur.buildOrderDrifted
|
|
427
|
+
? '⚠️ BUILD-ORDER.md is stale — run node scripts/build-order.mjs'
|
|
428
|
+
: '✅ BUILD-ORDER.md up to date'
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Only when the project turned the signal on (stalePreviewAgeDays) — an unconfigured signal is silent,
|
|
433
|
+
// not a nightly "unavailable" line nobody can act on.
|
|
434
|
+
if (stalePreviewAgeDays && (!prev || prev.stalePreviews !== cur.stalePreviews)) {
|
|
435
|
+
lines.push(
|
|
436
|
+
cur.stalePreviews == null
|
|
437
|
+
? '🧹 Stale previews: unavailable'
|
|
438
|
+
: `🧹 Stale previews (>${stalePreviewAgeDays}d): ${cur.stalePreviews}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---- Telegram ----
|
|
446
|
+
|
|
447
|
+
async function sendTelegram(chatId, text) {
|
|
448
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
449
|
+
if (!token) die('TELEGRAM_BOT_TOKEN is not set — export it before running standup.mjs.');
|
|
450
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true }),
|
|
454
|
+
signal: AbortSignal.timeout(10000),
|
|
455
|
+
});
|
|
456
|
+
let body = {};
|
|
457
|
+
try {
|
|
458
|
+
body = await res.json();
|
|
459
|
+
} catch {
|
|
460
|
+
/* non-JSON error body */
|
|
461
|
+
}
|
|
462
|
+
if (!res.ok) die(`Telegram sendMessage failed: ${res.status} ${JSON.stringify(body).slice(0, 300)}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---- log persistence (scripts/lib/log-branch.mjs — a dedicated claude/-prefixed branch) ----
|
|
466
|
+
|
|
467
|
+
function appendRunAndPush(snapshot) {
|
|
468
|
+
const ok = appendLineToBranch({
|
|
469
|
+
cwd: ROOT,
|
|
470
|
+
branch: LOG_BRANCH,
|
|
471
|
+
path: LOG_BRANCH_PATH,
|
|
472
|
+
line: `${JSON.stringify(snapshot)}\n`,
|
|
473
|
+
message: `chore(standup): log ${new Date().toISOString().slice(0, 10)}`,
|
|
474
|
+
});
|
|
475
|
+
if (!ok) {
|
|
476
|
+
console.error(
|
|
477
|
+
`standup: writing the updated log to ${LOG_BRANCH} failed — the next run (esp. a fresh routine ` +
|
|
478
|
+
`session) won't see today's snapshot and will re-derive its baseline.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---- main ----
|
|
484
|
+
|
|
485
|
+
async function main() {
|
|
486
|
+
let config;
|
|
487
|
+
try {
|
|
488
|
+
config = loadReportingConfig({ root: ROOT });
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (e instanceof ReportingConfigError) die(e.message);
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
ensureGh();
|
|
494
|
+
|
|
495
|
+
const repoSignals = config.repos.map(gatherRepoPrs);
|
|
496
|
+
const smoke = gatherSmoke(config.smoke);
|
|
497
|
+
const buildOrder = gatherBuildOrderDrift();
|
|
498
|
+
const previews = gatherStalePreviews(config.stalePreviewAgeDays, config.vercelProject);
|
|
499
|
+
|
|
500
|
+
const cur = buildSnapshot({ repoSignals, smoke, buildOrder, previews });
|
|
501
|
+
const prev = loadLastRun();
|
|
502
|
+
const deltaLines = diffSnapshots(prev, cur, repoSignals, {
|
|
503
|
+
stalePreviewAgeDays: config.stalePreviewAgeDays,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const header = `<b>Standup · ${cur.ts.slice(0, 10)}</b>`;
|
|
507
|
+
|
|
508
|
+
// ── Phase 1: --brief ───────────────────────────────────────────────────────────────────────
|
|
509
|
+
// Prints persona + lessons + evidence and exits. Fully read-only: no log append, no git, no
|
|
510
|
+
// Telegram, no registry write — the same guarantee --dry-run carries.
|
|
511
|
+
if (BRIEF) {
|
|
512
|
+
const roadmapDeltas = gatherRoadmapDeltas();
|
|
513
|
+
const windowLabel = `since the last standup (${cur.ts.slice(0, 10)})`;
|
|
514
|
+
// D7: an empty window must never produce a writable brief. Roadmap movement AND repo movement
|
|
515
|
+
// both absent is the only case that counts as quiet.
|
|
516
|
+
if (!roadmapDeltas.length && !deltaLines.length) {
|
|
517
|
+
writeSync(1, `${buildQuietBrief(windowLabel)}\n`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const pack = buildEvidencePack({
|
|
521
|
+
windowLabel,
|
|
522
|
+
roadmapDeltas,
|
|
523
|
+
owed: loadOwedLedger(OWED_LEDGER_PATH),
|
|
524
|
+
repoSignals: deltaLines.map((l) => telegramHtmlToConsoleText(l)),
|
|
525
|
+
liveFlags: gatherLiveFlags(config.liveFlags),
|
|
526
|
+
smoke: smoke.available ? smoke.conclusion || smoke.status : null,
|
|
527
|
+
});
|
|
528
|
+
writeSync(1, `${buildBrief({ scriptsDir: __dirname, surface: 'standup', pack })}\n`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Phase 3: --post --prose-file ───────────────────────────────────────────────────────────
|
|
533
|
+
// Guards a routine-written draft with the SAME pure module the local rail uses, then posts.
|
|
534
|
+
// `--post` REQUIRES an explicit mode. Cross-review caught that `--post` alone fell through to the
|
|
535
|
+
// legacy unguarded send, so a typo could publish a report with no guard and no error at all.
|
|
536
|
+
if (POST && !PROSE_FILE && !QUIET) {
|
|
537
|
+
die(
|
|
538
|
+
'--post needs either --prose-file <path> (a guarded prose post) or --quiet (the one-line\n' +
|
|
539
|
+
' quiet-window message). Refusing to fall through to an unguarded send.'
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let prose = null;
|
|
544
|
+
if (POST && PROSE_FILE) {
|
|
545
|
+
const draft = readFileSync(resolve(ROOT, PROSE_FILE), 'utf8').trim();
|
|
546
|
+
// Evidence is DERIVED from the window, never hardcoded. Cross-review caught that these were
|
|
547
|
+
// fixed empty arrays, which made `allowsFixClaim`/`allowsBeneficiary` permanently false — so a
|
|
548
|
+
// genuine `fix:` in the window, or real customer-facing work, had its compliant prose rejected.
|
|
549
|
+
const windowFacts = gatherWindowFacts();
|
|
550
|
+
const evidence = deriveEvidenceFlags({
|
|
551
|
+
subjects: windowFacts.subjects,
|
|
552
|
+
areas: windowFacts.areas,
|
|
553
|
+
liveFlags: gatherLiveFlags(config.liveFlags).flags,
|
|
554
|
+
maxWords: STANDUP_MAX_WORDS,
|
|
555
|
+
});
|
|
556
|
+
// jev-semantic-guards S3.2: the semantic families are judged by Jev per jev.config.json → rails.prose.mode;
|
|
557
|
+
// `checkProse` is the fallback and, with the rail off, the whole of it. The decider is on the result.
|
|
558
|
+
const verdict = await judgeProse(draft, {
|
|
559
|
+
...evidence,
|
|
560
|
+
extraBannedToolNames: config.prose.extraBannedToolNames,
|
|
561
|
+
});
|
|
562
|
+
const found = verdict.findings.map((f) => f.code).join(', ');
|
|
563
|
+
process.stderr.write(`prose guard: decided by ${verdict.decider} (${verdict.mode}) — ${found || 'clean'}\n`);
|
|
564
|
+
if (!verdict.ok && !FORCE_POST) {
|
|
565
|
+
// Non-zero exit + the numbered revision note. The routine revises once and re-runs with
|
|
566
|
+
// --force-post (D3: a labelled imperfect report beats a missing one).
|
|
567
|
+
process.stderr.write(`${findingsToRevisionNote(verdict.findings)}\n`);
|
|
568
|
+
process.exit(2);
|
|
569
|
+
}
|
|
570
|
+
prose = verdict.ok
|
|
571
|
+
? draft
|
|
572
|
+
: `${draft}\n\n<i>⚠ flagged draft — ${verdict.findings.map((f) => f.code).join(', ')}</i>`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// D8: prose leads, compact signals below. The guard forbids naming specifics, so a red CI cannot
|
|
576
|
+
// survive the paragraph — the signal block is where the actionable pointers live, not decoration.
|
|
577
|
+
const rawMessage = prose
|
|
578
|
+
? [
|
|
579
|
+
header,
|
|
580
|
+
'',
|
|
581
|
+
esc(prose)
|
|
582
|
+
.replace(/<i>/g, '<i>')
|
|
583
|
+
.replace(/<\/i>/g, '</i>'),
|
|
584
|
+
'',
|
|
585
|
+
...deltaLines,
|
|
586
|
+
].join('\n')
|
|
587
|
+
: deltaLines.length
|
|
588
|
+
? [header, ...deltaLines].join('\n')
|
|
589
|
+
: `${header}\n🌙 Quiet night — nothing new since the last standup.`;
|
|
590
|
+
const artifacts = buildStandupArtifacts({
|
|
591
|
+
docViewerUrl: config.artifacts.docViewerUrl,
|
|
592
|
+
snapshot: cur,
|
|
593
|
+
deltaLines,
|
|
594
|
+
generatedAt: new Date(cur.ts),
|
|
595
|
+
});
|
|
596
|
+
// reporthub-as-notion S1.3: try to upgrade the standup deck's URL-hash link to a short gs://-backed
|
|
597
|
+
// /r/<slug> link (scripts/lib/report-registry.mjs). `--dry-run` must stay fully read-only, so it's
|
|
598
|
+
// passed straight through as `dryRun` — a dry run logs the would-be slug/link but performs no upload.
|
|
599
|
+
// On any real-run upload failure the artifact keeps the URL-hash link it already had.
|
|
600
|
+
await upgradeArtifactLinks(artifacts, {
|
|
601
|
+
date: new Date(cur.ts),
|
|
602
|
+
dryRun: DRY_RUN,
|
|
603
|
+
baseUrl: config.artifacts.registry?.resolverBaseUrl,
|
|
604
|
+
bucket: process.env.REPORT_REGISTRY_BUCKET || config.artifacts.registry?.bucket,
|
|
605
|
+
});
|
|
606
|
+
// Last-resort safety net for Telegram's hard 4096-char limit — the per-repo caps above (baseline
|
|
607
|
+
// summary lines, formatPrList) should already keep any normal night well under this.
|
|
608
|
+
const message = appendStandupArtifactsToMessage(rawMessage, artifacts, TELEGRAM_MAX_CHARS);
|
|
609
|
+
|
|
610
|
+
console.log(telegramHtmlToConsoleText(message));
|
|
611
|
+
|
|
612
|
+
if (!DRY_RUN) {
|
|
613
|
+
const chatId = chatIdFor(config, 'standup');
|
|
614
|
+
if (!chatId) {
|
|
615
|
+
die(
|
|
616
|
+
'No Telegram chat id configured — set "telegram.chatIds.standup" or "telegram.chatId" in ' +
|
|
617
|
+
'reporting.config.json, or export TELEGRAM_CHAT_ID.'
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
await sendTelegram(chatId, message);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!DRY_RUN) {
|
|
624
|
+
appendRunAndPush(cur);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
629
|
+
if (isMain) {
|
|
630
|
+
main().catch((e) => {
|
|
631
|
+
console.error(e.message || e);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
});
|
|
634
|
+
}
|