@golden-frijoles/kit 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +179 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
formatTelegramHtmlLink,
|
|
5
|
+
telegramHtmlVisibleLength,
|
|
6
|
+
truncateForTelegram,
|
|
7
|
+
} from './telegram-format.mjs';
|
|
8
|
+
import { buildDocViewerUrl } from './pmo-templates.mjs';
|
|
9
|
+
import { kitRoot } from './project-root.mjs';
|
|
10
|
+
|
|
11
|
+
const TEMPLATE_PATH = join(kitRoot(), 'standup', 'templates', 'daily-story-deck.md'); // D2: a kit asset
|
|
12
|
+
const TELEGRAM_MAX_CHARS = 4096;
|
|
13
|
+
|
|
14
|
+
export const STANDUP_DECK_ASPECT_RATIO = '16:9';
|
|
15
|
+
|
|
16
|
+
function shortRepo(repo) {
|
|
17
|
+
return repo.split('/')[1] || repo;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function decodeTelegramEntities(text) {
|
|
21
|
+
return String(text ?? '')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/&/g, '&');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function telegramHtmlToMarkdown(text) {
|
|
28
|
+
return decodeTelegramEntities(
|
|
29
|
+
String(text ?? '')
|
|
30
|
+
.replace(/<b>(.*?)<\/b>/g, '**$1**')
|
|
31
|
+
.replace(/<[^>]*>/g, '')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bulletList(lines, fallback) {
|
|
36
|
+
const items = lines.filter(Boolean);
|
|
37
|
+
if (!items.length) return `- ${fallback}`;
|
|
38
|
+
return items.map((line) => `- ${line}`).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPath(obj, path) {
|
|
42
|
+
return path.split('.').reduce((cur, part) => (cur == null ? undefined : cur[part]), obj);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fillStandupTemplate(template, data) {
|
|
46
|
+
return template.replace(/\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g, (match, key) => {
|
|
47
|
+
const value = getPath(data, key);
|
|
48
|
+
return value === undefined ? match : String(value);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildStandupDeckData({ snapshot, deltaLines = [], generatedAt = new Date() }) {
|
|
53
|
+
const date = String(snapshot?.ts || generatedAt.toISOString()).slice(0, 10);
|
|
54
|
+
const repos = Object.entries(snapshot?.repos || {}).map(([repo, state]) => {
|
|
55
|
+
if (!state) return `**${shortRepo(repo)}:** unavailable`;
|
|
56
|
+
return (
|
|
57
|
+
`**${shortRepo(repo)}:** ${state.openNumbers?.length || 0} open; ` +
|
|
58
|
+
`${state.failingOpenNumbers?.length || 0} red; ${state.conflictingOpenNumbers?.length || 0} conflicts`
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
const smoke = snapshot?.smoke
|
|
62
|
+
? `${snapshot.smoke.conclusion || snapshot.smoke.status || 'unknown'} (${String(snapshot.smoke.createdAt || '').slice(0, 10) || 'no date'})`
|
|
63
|
+
: 'unavailable';
|
|
64
|
+
const stalePreviews = snapshot?.stalePreviews == null ? 'unavailable' : String(snapshot.stalePreviews);
|
|
65
|
+
return {
|
|
66
|
+
deck: {
|
|
67
|
+
aspectRatio: STANDUP_DECK_ASPECT_RATIO,
|
|
68
|
+
},
|
|
69
|
+
window: {
|
|
70
|
+
date,
|
|
71
|
+
generatedDate: generatedAt.toISOString().slice(0, 10),
|
|
72
|
+
},
|
|
73
|
+
summary: {
|
|
74
|
+
bullets: bulletList(
|
|
75
|
+
deltaLines.map(telegramHtmlToMarkdown),
|
|
76
|
+
'Quiet night - nothing new since the last standup.'
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
repos: {
|
|
80
|
+
bullets: bulletList(repos, 'Repository signals unavailable.'),
|
|
81
|
+
},
|
|
82
|
+
guards: {
|
|
83
|
+
browserSmoke: smoke,
|
|
84
|
+
buildOrder: snapshot?.buildOrderDrifted ? 'drift detected' : 'up to date',
|
|
85
|
+
stalePreviews,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadStandupDeckTemplate() {
|
|
91
|
+
return readFileSync(TEMPLATE_PATH, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildStandupDeckMarkdown(options) {
|
|
95
|
+
return fillStandupTemplate(loadStandupDeckTemplate(), buildStandupDeckData(options));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// No viewer configured → no artifact. The Telegram text is the canonical read; the deck is an optional
|
|
99
|
+
// extra, and an extra must never be faked with someone else's hosting.
|
|
100
|
+
export function buildStandupArtifacts({ docViewerUrl, ...options } = {}) {
|
|
101
|
+
if (!docViewerUrl) return [];
|
|
102
|
+
const markdown = buildStandupDeckMarkdown(options);
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
name: 'standup',
|
|
106
|
+
markdown,
|
|
107
|
+
url: buildDocViewerUrl(markdown, { baseUrl: docViewerUrl, present: true }),
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function appendStandupArtifactsToMessage(message, artifacts, maxChars = TELEGRAM_MAX_CHARS) {
|
|
113
|
+
if (!artifacts.length) return truncateForTelegram(message, maxChars);
|
|
114
|
+
const suffix = `\n\n${artifacts
|
|
115
|
+
.map((artifact) => `Deck ${artifact.name}: ${formatTelegramHtmlLink('open daily story', artifact.url)}`)
|
|
116
|
+
.join('\n')}`;
|
|
117
|
+
const suffixVisibleLength = telegramHtmlVisibleLength(suffix);
|
|
118
|
+
if (suffixVisibleLength >= maxChars) return truncateForTelegram(message, maxChars);
|
|
119
|
+
return `${truncateForTelegram(message, maxChars - suffixVisibleLength)}${suffix}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// telegram-format.mjs — shared message-formatting safety nets for Telegram's hard 4096-char
|
|
2
|
+
// sendMessage limit. Used by both standup.mjs and weekly-recap.mjs (originally built for weekly-recap,
|
|
3
|
+
// moved here 2026-07-03 after standup.mjs hit the exact same failure mode live: a missing/wiped delta
|
|
4
|
+
// log made it enumerate gh's entire recent-PR history as "new," overflowing the limit and dying before
|
|
5
|
+
// ever posting or persisting its own log).
|
|
6
|
+
|
|
7
|
+
function esc(s) {
|
|
8
|
+
return String(s ?? '')
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function escAttr(s) {
|
|
15
|
+
return esc(s).replace(/"/g, '"');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeHtmlEntities(s) {
|
|
19
|
+
return String(s ?? '')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/&/g, '&');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatTelegramHtmlLink(label, url) {
|
|
28
|
+
return `<a href="${escAttr(url)}">${esc(label)}</a>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function telegramHtmlVisibleText(text) {
|
|
32
|
+
return decodeHtmlEntities(
|
|
33
|
+
String(text ?? '')
|
|
34
|
+
.replace(/<a\s+href="[^"]*">([\s\S]*?)<\/a>/g, '$1')
|
|
35
|
+
.replace(/<[^>]*>/g, '')
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function telegramHtmlVisibleLength(text) {
|
|
40
|
+
return telegramHtmlVisibleText(text).length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function telegramHtmlToConsoleText(text) {
|
|
44
|
+
return decodeHtmlEntities(
|
|
45
|
+
String(text ?? '')
|
|
46
|
+
.replace(/<a\s+href="([^"]*)">([\s\S]*?)<\/a>/g, '$2 ($1)')
|
|
47
|
+
.replace(/<[^>]*>/g, '')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pure — caps a list of {number, title} PRs to maxItems titles before it can dominate a message on a
|
|
52
|
+
// busy day/week, folding the rest into a "…and N more" tail. The caller's own section-header count is
|
|
53
|
+
// never capped, only the listed titles are.
|
|
54
|
+
export function formatPrList(prs, maxItems) {
|
|
55
|
+
const shown = prs
|
|
56
|
+
.slice(0, maxItems)
|
|
57
|
+
.map((p) => `#${p.number} ${esc(p.title)}`)
|
|
58
|
+
.join('; ');
|
|
59
|
+
const rest = prs.length - maxItems;
|
|
60
|
+
return rest > 0 ? `${shown}; …and ${rest} more` : shown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pure — a last-resort safety net for Telegram's hard 4096-char sendMessage limit. formatPrList (or an
|
|
64
|
+
// equivalent per-caller cap) should already keep a normal message well under this; this only bites an
|
|
65
|
+
// extreme outlier so the API call never gets rejected outright instead of silently never posting. Two
|
|
66
|
+
// HTML-safety steps, since the only tags these scripts emit are `<b>`/`</b>` and an unbalanced/incomplete
|
|
67
|
+
// one would make Telegram reject the whole message as invalid HTML — defeating the safety net's purpose:
|
|
68
|
+
// (1) strip a trailing PARTIAL tag first (the cut landing mid-`<b>`/`</b>` itself, e.g. a dangling `<b`),
|
|
69
|
+
// (2) then close any remaining fully-formed-but-unclosed `<b>`. Appending closing tags can itself push
|
|
70
|
+
// the result back OVER `limit` — iterate, shrinking the cut point by however much was appended, until the
|
|
71
|
+
// FINAL (post-closing-tags) length actually fits. Bounded to a few iterations (closing tags are 4 chars
|
|
72
|
+
// each, so this converges immediately in every realistic case); a hard fallback strips all tags entirely
|
|
73
|
+
// if it somehow doesn't, guaranteeing the contract ("result never exceeds limit") holds regardless.
|
|
74
|
+
export function truncateForTelegram(text, limit) {
|
|
75
|
+
if (text.length <= limit) return text;
|
|
76
|
+
let cut = limit - 1; // room for the ellipsis
|
|
77
|
+
for (let i = 0; i < 10; i += 1) {
|
|
78
|
+
const sliced = text.slice(0, cut).replace(/<[^>]*$/, '');
|
|
79
|
+
const candidate = `${sliced.trim()}…`;
|
|
80
|
+
const opens = (candidate.match(/<b>/g) || []).length;
|
|
81
|
+
const closes = (candidate.match(/<\/b>/g) || []).length;
|
|
82
|
+
const closingTags = opens > closes ? '</b>'.repeat(opens - closes) : '';
|
|
83
|
+
const final = `${candidate}${closingTags}`;
|
|
84
|
+
if (final.length <= limit) return final;
|
|
85
|
+
cut -= Math.max(closingTags.length, 1);
|
|
86
|
+
}
|
|
87
|
+
return `${text
|
|
88
|
+
.replace(/<[^>]*>/g, '')
|
|
89
|
+
.slice(0, limit - 1)
|
|
90
|
+
.trim()}…`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* live-smoke — the scripted, cross-agent-usable default for verifying real, rendered behavior against
|
|
4
|
+
* local/preview/staging/prod, unauthed or authed.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the origin project's app-local script by plugin-audit-and-extraction S1 (D3: port, don't
|
|
7
|
+
* drop). Every project-specific value — the app dir, the environment URLs, the role flows, the auth
|
|
8
|
+
* provider's key names — moved to `live-smoke.config.json` at the repo root. The refusals the origin
|
|
9
|
+
* learned the hard way are kept: authed flows never run against an env marked unsupported (auth providers
|
|
10
|
+
* reject testing tokens for production keys by design), and keys that look like production keys are
|
|
11
|
+
* refused even at --env=local.
|
|
12
|
+
*
|
|
13
|
+
* Wraps the app's existing Playwright harness (`<appDir>/playwright.config.ts`'s `browser` project) — no
|
|
14
|
+
* new browser-driving logic, just env resolution + a structured result an agent can parse.
|
|
15
|
+
*
|
|
16
|
+
* Three modes (exactly one):
|
|
17
|
+
* --path <url-path> [--flow unauthed|<role>] ad-hoc: runs e2e/_live/ad-hoc.browser.spec.ts on ONE path
|
|
18
|
+
* --spec <name> a COMMITTED *.browser.spec.ts whose TEST TITLE matches
|
|
19
|
+
* --file <path> every test in a committed spec, by file path
|
|
20
|
+
*
|
|
21
|
+
* --env: any key under `envs` in the config, plus `preview` (needs --preview-url). This script starts
|
|
22
|
+
* nothing itself — the target must already be running.
|
|
23
|
+
*
|
|
24
|
+
* Output: exit code (0 = pass), <appDir>/test-results/live-smoke/report.json and screenshot.png for a
|
|
25
|
+
* --path run. Read both back — a 0 with an unexpected screenshot is still worth flagging.
|
|
26
|
+
*
|
|
27
|
+
* node scripts/live-smoke.mjs --env=local --path=/
|
|
28
|
+
* node scripts/live-smoke.mjs --env=prod --path=/pricing
|
|
29
|
+
* node scripts/live-smoke.mjs --env=local --flow=admin --path=/admin
|
|
30
|
+
* node scripts/live-smoke.mjs --env=local --spec="home renders"
|
|
31
|
+
*/
|
|
32
|
+
import { parseArgs } from 'node:util';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { existsSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { join, isAbsolute, normalize } from 'node:path';
|
|
37
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
38
|
+
|
|
39
|
+
const REPO_ROOT = projectRoot(); // D2
|
|
40
|
+
export const CONFIG_FILENAME = 'live-smoke.config.json';
|
|
41
|
+
const OUT_DIR = process.env.LIVE_SMOKE_OUT ?? 'test-results/live-smoke';
|
|
42
|
+
|
|
43
|
+
/** Strip a matching pair of surrounding quotes (single or double), if present. */
|
|
44
|
+
function unquote(value) {
|
|
45
|
+
if (value.length >= 2) {
|
|
46
|
+
const first = value[0];
|
|
47
|
+
const last = value[value.length - 1];
|
|
48
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pure — parse a .env-style file without printing values or mutating process.env. Handles a leading
|
|
55
|
+
* `export ` and quoted values (both common in a hand-edited .env.local): a plain `KEY=value` parse would
|
|
56
|
+
* pass a literal `"sk_test_..."` (quotes included) to the auth provider and fail with a confusing error.
|
|
57
|
+
*/
|
|
58
|
+
export function parseDotEnv(text) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const line of text.split('\n')) {
|
|
61
|
+
let trimmed = line.trim();
|
|
62
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
63
|
+
if (trimmed.startsWith('export ')) trimmed = trimmed.slice('export '.length).trim();
|
|
64
|
+
const eq = trimmed.indexOf('=');
|
|
65
|
+
if (eq === -1) continue;
|
|
66
|
+
out[trimmed.slice(0, eq).trim()] = unquote(trimmed.slice(eq + 1).trim());
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pure — validate the config shape. Throws with the offending key named. */
|
|
72
|
+
export function validateConfig(raw, path = CONFIG_FILENAME) {
|
|
73
|
+
const fail = (m) => {
|
|
74
|
+
throw new Error(`${path}: ${m}`);
|
|
75
|
+
};
|
|
76
|
+
if (!raw || typeof raw !== 'object') fail('must be a JSON object');
|
|
77
|
+
if (typeof raw.appDir !== 'string' || !raw.appDir)
|
|
78
|
+
fail('"appDir" must name the app directory (e.g. "apps/web")');
|
|
79
|
+
// Relative and inside the repo: live-smoke reads <appDir>/.env.local, so an appDir that climbs out of the
|
|
80
|
+
// repo would read some other tree's secrets. (Cross-review finding on PR #20.)
|
|
81
|
+
if (isAbsolute(raw.appDir) || normalize(raw.appDir).split(/[\\/]/)[0] === '..') {
|
|
82
|
+
fail('"appDir" must be a relative path inside the repo (e.g. "apps/web")');
|
|
83
|
+
}
|
|
84
|
+
const envs = raw.envs ?? {};
|
|
85
|
+
if (!Object.keys(envs).length) fail('"envs" must map at least one env name to a base URL');
|
|
86
|
+
for (const [k, v] of Object.entries(envs)) {
|
|
87
|
+
if (k === 'preview') fail('"envs.preview" is reserved — preview URLs come from --preview-url');
|
|
88
|
+
if (!/^https?:\/\//.test(v)) fail(`"envs.${k}" must be an http(s) URL`);
|
|
89
|
+
}
|
|
90
|
+
const flows = raw.flows ?? {};
|
|
91
|
+
for (const [k, v] of Object.entries(flows)) {
|
|
92
|
+
if (k === 'unauthed') fail('"flows.unauthed" is implicit — list only role flows');
|
|
93
|
+
if (!v || typeof v.identityEnv !== 'string')
|
|
94
|
+
fail(`"flows.${k}.identityEnv" must name the env var holding that role's test identity`);
|
|
95
|
+
}
|
|
96
|
+
const auth = raw.auth ?? {};
|
|
97
|
+
return {
|
|
98
|
+
appDir: raw.appDir,
|
|
99
|
+
envs,
|
|
100
|
+
flows,
|
|
101
|
+
auth: {
|
|
102
|
+
requiredEnv: auth.requiredEnv ?? [],
|
|
103
|
+
forbidKeyPattern: auth.forbidKeyPattern ?? null,
|
|
104
|
+
unsupportedEnvs: auth.unsupportedEnvs ?? [],
|
|
105
|
+
},
|
|
106
|
+
previewBypassEnv: raw.previewBypassEnv ?? null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sameOrigin(path) {
|
|
111
|
+
const probe = 'https://target.invalid';
|
|
112
|
+
try {
|
|
113
|
+
return new URL(path, probe).origin === probe;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pure — turn args + config + the resolvable environment into a run plan, or an error string. Every
|
|
121
|
+
* refusal the script makes is decided here so a test can pin it without spawning a browser.
|
|
122
|
+
*/
|
|
123
|
+
export function planRun({ args, config, env = {}, dotenv = {} }) {
|
|
124
|
+
const flow = args.flow ?? 'unauthed';
|
|
125
|
+
const flowNames = ['unauthed', ...Object.keys(config.flows)];
|
|
126
|
+
if (!flowNames.includes(flow))
|
|
127
|
+
return { error: `--flow must be one of ${flowNames.join('|')} (got "${flow}")` };
|
|
128
|
+
|
|
129
|
+
// A path, not a URL: `//host/x` is protocol-relative and would silently smoke a DIFFERENT host than the
|
|
130
|
+
// resolved --env, and a scheme would override it outright. (Cross-review finding on PR #20.)
|
|
131
|
+
// Checked by RESOLUTION, not by prefix: `/\\host` resolves to another origin too (WHATWG treats `\\` as
|
|
132
|
+
// `/`), so the rule is simply that the path must land on the base URL's own origin.
|
|
133
|
+
if (
|
|
134
|
+
args.path != null &&
|
|
135
|
+
(typeof args.path !== 'string' || !args.path.startsWith('/') || !sameOrigin(args.path))
|
|
136
|
+
) {
|
|
137
|
+
return {
|
|
138
|
+
error: `--path must be a path on the target env, starting with a single "/" (got "${args.path}")`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const modes = [args.path, args.spec, args.file].filter(Boolean);
|
|
143
|
+
if (modes.length === 0)
|
|
144
|
+
return { error: 'pass --path=<url-path> (ad-hoc), --spec=<name>, or --file=<path> (a committed spec)' };
|
|
145
|
+
if (modes.length > 1) return { error: 'pass exactly one of --path, --spec, --file' };
|
|
146
|
+
|
|
147
|
+
const envName = args.env ?? Object.keys(config.envs)[0];
|
|
148
|
+
let baseURL;
|
|
149
|
+
if (envName === 'preview') {
|
|
150
|
+
if (!args['preview-url']) return { error: '--env=preview requires --preview-url=<https://...>' };
|
|
151
|
+
baseURL = args['preview-url'];
|
|
152
|
+
} else {
|
|
153
|
+
baseURL = config.envs[envName];
|
|
154
|
+
if (!baseURL)
|
|
155
|
+
return {
|
|
156
|
+
error: `unknown --env "${envName}" (expected ${[...Object.keys(config.envs), 'preview'].join('|')})`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const childEnv = { PLAYWRIGHT_BASE_URL: baseURL };
|
|
161
|
+
if (envName === 'preview' && config.previewBypassEnv) {
|
|
162
|
+
const bypass = env[config.previewBypassEnv];
|
|
163
|
+
if (!bypass)
|
|
164
|
+
return { error: `--env=preview needs ${config.previewBypassEnv} set in the shell environment` };
|
|
165
|
+
childEnv[config.previewBypassEnv] = bypass;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (flow !== 'unauthed') {
|
|
169
|
+
// The env constraint FIRST — a specific refusal beats a generic "need keys" message when both apply.
|
|
170
|
+
if (config.auth.unsupportedEnvs.includes(envName)) {
|
|
171
|
+
return {
|
|
172
|
+
error:
|
|
173
|
+
`--flow=${flow} against --env=${envName} is not supported — auth providers reject testing tokens ` +
|
|
174
|
+
'for production keys by design. Use a dev/test environment for authed flows.',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
for (const key of config.auth.requiredEnv) {
|
|
178
|
+
const value = env[key] ?? dotenv[key];
|
|
179
|
+
if (!value)
|
|
180
|
+
return { error: `an authed --flow needs ${key} resolvable from the shell or <appDir>/.env.local` };
|
|
181
|
+
// Defense-in-depth beyond the env check: the same rejection happens at --env=local when .env.local
|
|
182
|
+
// carries production keys. Catch it here with a clear message, not deep inside a sign-in helper.
|
|
183
|
+
if (config.auth.forbidKeyPattern && value.includes(config.auth.forbidKeyPattern)) {
|
|
184
|
+
return {
|
|
185
|
+
error:
|
|
186
|
+
`${key} looks like a PRODUCTION key (contains "${config.auth.forbidKeyPattern}") — authed ` +
|
|
187
|
+
'live-smoke flows only work with a dev/test auth instance.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
childEnv[key] = value;
|
|
191
|
+
}
|
|
192
|
+
const identityEnv = config.flows[flow].identityEnv;
|
|
193
|
+
const identity = env[identityEnv] ?? dotenv[identityEnv];
|
|
194
|
+
if (identity) childEnv[identityEnv] = identity;
|
|
195
|
+
childEnv[`LIVE_SMOKE_IDENTITY_ENV_${flow.toUpperCase()}`] = identityEnv;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let playwrightArgs;
|
|
199
|
+
if (args.path) {
|
|
200
|
+
Object.assign(childEnv, { LIVE_SMOKE_PATH: args.path, LIVE_SMOKE_FLOW: flow, LIVE_SMOKE_OUT: OUT_DIR });
|
|
201
|
+
playwrightArgs = ['playwright', 'test', '--project=browser', 'e2e/_live/ad-hoc.browser.spec.ts'];
|
|
202
|
+
} else if (args.spec) {
|
|
203
|
+
playwrightArgs = ['playwright', 'test', '--project=browser', '-g', args.spec];
|
|
204
|
+
} else {
|
|
205
|
+
playwrightArgs = ['playwright', 'test', '--project=browser', args.file];
|
|
206
|
+
}
|
|
207
|
+
return { envName, baseURL, flow, childEnv, playwrightArgs };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function loadConfig({ root = REPO_ROOT } = {}) {
|
|
211
|
+
const path = join(root, CONFIG_FILENAME);
|
|
212
|
+
if (!existsSync(path)) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`${path} not found — live-smoke refuses to guess which app and which URLs to smoke.\n` +
|
|
215
|
+
` Copy live-smoke.config.example.json to ${CONFIG_FILENAME}, fill it in, and commit it.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return validateConfig(JSON.parse(readFileSync(path, 'utf8')), path);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function die(message) {
|
|
222
|
+
console.error(`live-smoke: ${message}`);
|
|
223
|
+
process.exit(2);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function main() {
|
|
227
|
+
const { values: args } = parseArgs({
|
|
228
|
+
options: {
|
|
229
|
+
env: { type: 'string' },
|
|
230
|
+
flow: { type: 'string', default: 'unauthed' },
|
|
231
|
+
path: { type: 'string' },
|
|
232
|
+
spec: { type: 'string' },
|
|
233
|
+
file: { type: 'string' },
|
|
234
|
+
'preview-url': { type: 'string' },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
let config;
|
|
238
|
+
try {
|
|
239
|
+
config = loadConfig();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
die(e.message);
|
|
242
|
+
}
|
|
243
|
+
const appRoot = join(REPO_ROOT, config.appDir);
|
|
244
|
+
if (
|
|
245
|
+
!existsSync(join(appRoot, 'playwright.config.ts')) &&
|
|
246
|
+
!existsSync(join(appRoot, 'playwright.config.js'))
|
|
247
|
+
) {
|
|
248
|
+
die(`${config.appDir} has no playwright.config — live-smoke wraps the app's own Playwright harness`);
|
|
249
|
+
}
|
|
250
|
+
const dotenvPath = join(appRoot, '.env.local');
|
|
251
|
+
const dotenv = existsSync(dotenvPath) ? parseDotEnv(readFileSync(dotenvPath, 'utf8')) : {};
|
|
252
|
+
|
|
253
|
+
const plan = planRun({ args, config, env: process.env, dotenv });
|
|
254
|
+
if (plan.error) die(plan.error);
|
|
255
|
+
|
|
256
|
+
if (args.path) {
|
|
257
|
+
// Clear a PREVIOUS run's evidence first — a skipped or crashed-before-navigating run leaves no fresh
|
|
258
|
+
// report, and the readback below would otherwise present stale evidence as current.
|
|
259
|
+
rmSync(join(appRoot, OUT_DIR, 'report.json'), { force: true });
|
|
260
|
+
rmSync(join(appRoot, OUT_DIR, 'screenshot.png'), { force: true });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const target = args.path ? `path=${args.path}` : args.spec ? `spec="${args.spec}"` : `file=${args.file}`;
|
|
264
|
+
console.log(`live-smoke: env=${plan.envName} baseURL=${plan.baseURL} flow=${plan.flow} ${target}`);
|
|
265
|
+
|
|
266
|
+
const result = spawnSync('npx', plan.playwrightArgs, {
|
|
267
|
+
cwd: appRoot,
|
|
268
|
+
env: { ...process.env, ...plan.childEnv },
|
|
269
|
+
stdio: 'inherit',
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (args.path) {
|
|
273
|
+
const reportPath = join(appRoot, OUT_DIR, 'report.json');
|
|
274
|
+
if (existsSync(reportPath)) {
|
|
275
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
276
|
+
console.log(`live-smoke: report at ${reportPath}`);
|
|
277
|
+
console.log(
|
|
278
|
+
`live-smoke: screenshot at ${report.screenshot ? join(appRoot, report.screenshot) : '(none)'}`
|
|
279
|
+
);
|
|
280
|
+
if (report.consoleErrors?.length) {
|
|
281
|
+
console.log(
|
|
282
|
+
`live-smoke: ${report.consoleErrors.length} browser console error(s) captured — see report.json`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
console.log(
|
|
287
|
+
'live-smoke: no report.json written (the spec likely skipped or crashed before navigating)'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
process.exit(result.status ?? 1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// realpath, not resolve: on macOS a temp or symlinked path (/var → /private/var) never equals the module
|
|
295
|
+
// URL, and a plain comparison makes the CLI a silent no-op.
|
|
296
|
+
const isMain = (() => {
|
|
297
|
+
try {
|
|
298
|
+
return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
if (isMain) main();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "pmo-benchmarks-v1",
|
|
3
|
+
"generatedAt": "2026-07-13",
|
|
4
|
+
"framing": "Differential, not a controlled experiment. DORA/Four Keys thresholds are used only for comparable delivery signals; throughput and doc-ops remain internal PMO signals.",
|
|
5
|
+
"figures": {
|
|
6
|
+
"deploysPerWeekDailyThreshold": {
|
|
7
|
+
"label": "Daily deployment-frequency threshold",
|
|
8
|
+
"value": 3,
|
|
9
|
+
"unit": "deployment days per week",
|
|
10
|
+
"direction": "higher_is_better",
|
|
11
|
+
"source": {
|
|
12
|
+
"title": "Four Keys Metrics - Deployment Frequency",
|
|
13
|
+
"publisher": "DORA / Four Keys",
|
|
14
|
+
"url": "https://github.com/dora-team/fourkeys/blob/main/METRICS.md#deployment-frequency",
|
|
15
|
+
"publishedDate": "2024-01-23",
|
|
16
|
+
"accessedDate": "2026-07-13",
|
|
17
|
+
"note": "Daily bucket: median number of days per week with deployments is >= 3."
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"prCycleMedianHoursOneDayThreshold": {
|
|
21
|
+
"label": "One-day lead-time threshold",
|
|
22
|
+
"value": 24,
|
|
23
|
+
"unit": "hours",
|
|
24
|
+
"direction": "lower_is_better",
|
|
25
|
+
"source": {
|
|
26
|
+
"title": "Four Keys Metrics - Lead Time for Changes",
|
|
27
|
+
"publisher": "DORA / Four Keys",
|
|
28
|
+
"url": "https://github.com/dora-team/fourkeys/blob/main/METRICS.md#lead-time-for-changes",
|
|
29
|
+
"publishedDate": "2024-01-23",
|
|
30
|
+
"accessedDate": "2026-07-13",
|
|
31
|
+
"note": "One-day bucket: median lead time to change < 24 * 60 minutes. PMO uses this as a proxy comparator for PR open-to-merge cycle time, not as an identical metric."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"epicLeadMedianDaysOneWeekThreshold": {
|
|
35
|
+
"label": "One-week lead-time threshold",
|
|
36
|
+
"value": 7,
|
|
37
|
+
"unit": "days",
|
|
38
|
+
"direction": "lower_is_better",
|
|
39
|
+
"source": {
|
|
40
|
+
"title": "Four Keys Metrics - Lead Time for Changes",
|
|
41
|
+
"publisher": "DORA / Four Keys",
|
|
42
|
+
"url": "https://github.com/dora-team/fourkeys/blob/main/METRICS.md#lead-time-for-changes",
|
|
43
|
+
"publishedDate": "2024-01-23",
|
|
44
|
+
"accessedDate": "2026-07-13",
|
|
45
|
+
"note": "One-week bucket: median lead time to change < 168 * 60 minutes. PMO uses this as a coarse comparator for epic scaffold-to-shipped lead time."
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"changeFailureRateMaxPercent": {
|
|
49
|
+
"label": "Top change-failure-rate bucket",
|
|
50
|
+
"value": 15,
|
|
51
|
+
"unit": "percent",
|
|
52
|
+
"direction": "lower_is_better",
|
|
53
|
+
"source": {
|
|
54
|
+
"title": "Four Keys Metrics - Change Failure Rate",
|
|
55
|
+
"publisher": "DORA / Four Keys",
|
|
56
|
+
"url": "https://github.com/dora-team/fourkeys/blob/main/METRICS.md#change-failure-rate",
|
|
57
|
+
"publishedDate": "2024-01-23",
|
|
58
|
+
"accessedDate": "2026-07-13",
|
|
59
|
+
"note": "Top bucket is 0-15% failures per deployments."
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"restoreTimeHoursOneDayThreshold": {
|
|
63
|
+
"label": "One-day time-to-restore threshold",
|
|
64
|
+
"value": 24,
|
|
65
|
+
"unit": "hours",
|
|
66
|
+
"direction": "lower_is_better",
|
|
67
|
+
"source": {
|
|
68
|
+
"title": "Four Keys Metrics - Time to Restore Services",
|
|
69
|
+
"publisher": "DORA / Four Keys",
|
|
70
|
+
"url": "https://github.com/dora-team/fourkeys/blob/main/METRICS.md#time-to-restore-services",
|
|
71
|
+
"publishedDate": "2024-01-23",
|
|
72
|
+
"accessedDate": "2026-07-13",
|
|
73
|
+
"note": "One-day bucket: median time to resolve < 24 hours. Included for future incident reporting; Sprint 2 templates do not yet render MTTR."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "PMO metrics sheet - {{window.label}}"
|
|
3
|
+
styles:
|
|
4
|
+
fontFamily: "Inter"
|
|
5
|
+
baseFontSize: 15
|
|
6
|
+
lineHeight: 1.6
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# PMO metrics sheet - {{window.label}}
|
|
10
|
+
|
|
11
|
+
```cells
|
|
12
|
+
Metric,Value,Benchmark,Direction,Differential
|
|
13
|
+
Deploys per week,{{deploys.perWeek}},{{benchmarks.deploysPerWeek}},higher is better,=B2-C2
|
|
14
|
+
PR cycle median hours,{{cycle.medianHours}},{{benchmarks.prCycleMedianHours}},lower is better,=B3-C3
|
|
15
|
+
Epic lead median days,{{epics.medianDays}},{{benchmarks.epicLeadMedianDays}},lower is better,=B4-C4
|
|
16
|
+
Change-failure proxy %,{{quality.changeFailureRatePercent}},{{benchmarks.changeFailureRatePercent}},lower is better,=B5-C5
|
|
17
|
+
Stories shipped,{{throughput.shippedStories}},0,internal signal,=B6-C6
|
|
18
|
+
Epics shipped,{{throughput.shippedEpics}},0,internal signal,=B7-C7
|
|
19
|
+
LEARNINGS promotions,{{docOps.learningsPromotions}},0,internal signal,=B8-C8
|
|
20
|
+
Retro coverage %,{{docOps.retroPercent}},0,internal signal,=B9-C9
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Notes:
|
|
24
|
+
|
|
25
|
+
- The formulas export to a spreadsheet as live formulas.
|
|
26
|
+
- Benchmark source: {{benchmarks.sourceLine}}.
|
|
27
|
+
- {{benchmarks.framing}}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "PMO monthly - {{window.label}}"
|
|
3
|
+
styles:
|
|
4
|
+
fontFamily: "Inter"
|
|
5
|
+
baseFontSize: 15
|
|
6
|
+
lineHeight: 1.65
|
|
7
|
+
h1: { fontSize: 2.0, fontWeight: 700 }
|
|
8
|
+
h2: { fontSize: 1.25, fontWeight: 600 }
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# PMO monthly - {{window.label}}
|
|
12
|
+
|
|
13
|
+
## Executive summary
|
|
14
|
+
|
|
15
|
+
This packet reports operational performance in PMO terms: flow, DORA, quality and documentation discipline.
|
|
16
|
+
The external comparison is a differential, not a controlled experiment.
|
|
17
|
+
|
|
18
|
+
## Indicators
|
|
19
|
+
|
|
20
|
+
| Indicator | Value |
|
|
21
|
+
|---|---:|
|
|
22
|
+
| Stories shipped | {{throughput.shippedStories}} |
|
|
23
|
+
| Epics shipped | {{throughput.shippedEpics}} |
|
|
24
|
+
| Deploys per week | {{deploys.perWeek}} |
|
|
25
|
+
| Median PR cycle | {{cycle.medianHours}}h |
|
|
26
|
+
| Median epic lead time | {{epics.medianDays}} days |
|
|
27
|
+
| Change-failure proxy | {{quality.changeFailureRatePercent}}% |
|
|
28
|
+
| LEARNINGS promotions | {{docOps.learningsPromotions}} |
|
|
29
|
+
| Retro coverage | {{docOps.retroCovered}}/{{docOps.retroTotal}} |
|
|
30
|
+
|
|
31
|
+
## External benchmarks
|
|
32
|
+
|
|
33
|
+
| Comparable signal | PMO | Benchmark | Reading |
|
|
34
|
+
|---|---:|---:|---|
|
|
35
|
+
| Deploys per week | {{deploys.perWeek}} | {{benchmarks.deploysPerWeek}} | Higher is better |
|
|
36
|
+
| Median PR cycle | {{cycle.medianHours}}h | {{benchmarks.prCycleMedianHours}}h | Lower is better |
|
|
37
|
+
| Median epic lead | {{epics.medianDays}} days | {{benchmarks.epicLeadMedianDays}} days | Lower is better |
|
|
38
|
+
| Change-failure proxy | {{quality.changeFailureRatePercent}}% | {{benchmarks.changeFailureRatePercent}}% | Lower is better |
|
|
39
|
+
|
|
40
|
+
Source: {{benchmarks.sourceLine}}. {{benchmarks.framing}}
|
|
41
|
+
|
|
42
|
+
## Visual evidence
|
|
43
|
+
|
|
44
|
+
```chart
|
|
45
|
+
{"type":"bar","title":"PMO monthly","labels":["Stories","Epics","Deploys","Doc ops"],"values":[{{throughput.shippedStories}},{{throughput.shippedEpics}},{{deploys.total}},{{docOps.learningsPromotions}}],"color":"#2563eb"}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Reading
|
|
49
|
+
|
|
50
|
+
- Throughput is inferred from real history, not story points.
|
|
51
|
+
- Deploy frequency equals merges to `main`, as WAYS-OF-WORKING defines it.
|
|
52
|
+
- The change-fail proxy counts reverts/hotfixes.
|
|
53
|
+
- Doc-ops measures the breadth of Roadmap, LEARNINGS and retrospective activity.
|