Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions growth-brain/ops/live-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Generated: 2026-08-06

| Metric | Count |
|---|---:|
| Prospects total | 0 |
| Active prospects | 0 |
| Active scored prospects | 0 |
| Scored including inactive | 0 |
| Prospects total | 50 |
| Active prospects | 43 |
| Active scored prospects | 5 |
| Scored including inactive | 12 |
| Looms recorded | 0 |
| Looms recorded including inactive | 0 |
| Ready to send | 0 |
Expand All @@ -22,7 +22,7 @@ Generated: 2026-08-06
| Due follow-up | 0 |
| Clients | 0 |
| Clients ready | 0 |
| Client records blocked | 0 |
| Client records blocked | 3 |

## Conversion Rates

Expand All @@ -38,7 +38,9 @@ Generated: 2026-08-06

| Stage | Count |
|---|---:|

| new | 38 |
| paused | 7 |
| scored | 5 |

## Decision Rule

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-market-parity-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function isOwnedStartupProof(clientPath) {
return /## Proof Type\s+owned-startup/i.test(read(join(clientPath, "proof-context.md")));
}

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
const sender = runJson(["scripts/check-outbound-sender-setup.mjs"]);
const kit = skipKit
? { status: "skipped", checkedFiles: 0, allowedCommands: 0 }
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-daily-money-mission.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ const rehearsal = runJson(["scripts/export-recording-rehearsal-check.mjs", `--li
runJson(["scripts/export-prospect-outbox.mjs"]);
runJson(["scripts/export-followup-cockpit.mjs"]);
runJson(["scripts/export-sales-cockpit.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
runJson(["scripts/export-proof-library.mjs"]);
runJson(["scripts/export-managed-it-one-pager.mjs"]);
const todayResult = runJson(["scripts/show-growth-command-center.mjs", `--limit=${Math.max(limit + 2, 7)}`]);
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-growth-cockpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ runJson(["scripts/export-prospect-outbox.mjs"]);
runJson(["scripts/export-followup-cockpit.mjs"]);
runJson(["scripts/export-sales-cockpit.mjs"]);
runJson(["scripts/export-daily-money-mission.mjs", "--limit=5"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Link the cockpit to the newly generated metrics report

When npm run growth:cockpit is run after the pipeline changes, this child call now refreshes runs/live-metrics.md, but the generated cockpit still links its “Live Metrics” button to ../growth-brain/ops/live-metrics.md (line 207). Because this change deliberately stops nested calls from refreshing that tracked file, the button presents the commit-time snapshot while the cockpit itself uses current counts; update the link and reported links entry to target the private report generated here.

Useful? React with 👍 / 👎.

runJson(["scripts/export-proof-library.mjs"]);
runJson(["scripts/export-managed-it-one-pager.mjs"]);
runJson(["scripts/export-growth-doctor.mjs", "--no-checks"]);
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-growth-doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ function statusFor(checks) {
return "ready";
}

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
const todayResult = runJson(["scripts/show-growth-command-center.mjs", "--limit=8"]);
const recordingPrep = recordingPrepStatus(5);
const rehearsal = runJson(["scripts/export-recording-rehearsal-check.mjs", "--limit=5"]);
Expand Down
31 changes: 30 additions & 1 deletion scripts/export-growth-metrics.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { isAbsolute, join, resolve } from "node:path";
import { localIsoDate } from "./date-utils.mjs";
import { checkProspectReadiness } from "./lib/prospect-readiness.mjs";
import { isValidLoomUrl } from "./lib/loom-url.mjs";
Expand All @@ -14,6 +14,35 @@ const plain = process.argv.includes("--plain");
const today = localIsoDate();
const repoRoot = process.env.SERVICE_REPO_ROOT || process.cwd();

// Every read and write is anchored to the service root (SERVICE_REPO_ROOT or
// the invocation directory) so live metrics regenerated from any working
// directory report the same pipeline state the rest of the operator surfaces
// read.
const resolvedOutputPath = isAbsolute(outputPath) ? outputPath : join(repoRoot, outputPath);
const prospectRoot = join(repoRoot, "prospects");
const trackedMetricsPath = join(repoRoot, "growth-brain/ops/live-metrics.md");
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Write the report through the resolved service-root path

When this script is invoked directly from a lane/code checkout with SERVICE_REPO_ROOT pointing to the canonical service root, resolvedOutputPath is used only to decide whether the guard applies; the final mkdirSync and writeFileSync still use outputPath, which is resolved against process.cwd(). The guard can therefore validate the canonical pipeline and exit successfully while overwriting the lane checkout's tracked metrics and leaving the canonical service-root report stale. Use the resolved path for directory creation and writing.

Useful? React with 👍 / 👎.


// Outbound pipeline state exists only when at least one real prospect folder
// carries a pipeline record. An absent prospects/ directory AND an empty one
// (for example one left behind by a private zero-state run) both mean the
// pipeline is unavailable, never empty.
const hasProspectPipelineState = existsSync(prospectRoot)
&& listFolders(prospectRoot).some((path) => existsSync(join(path, "pipeline.json")));

// The default output is a git-tracked operator surface. When the service root
// holds no outbound prospect pipeline state, regeneration cannot tell an empty
// pipeline from an unavailable one, so it refuses instead of silently
// clobbering the tracked surface with a zero pipeline. Explicit private
// outputs under runs/ keep generating zero-state reports on purpose.
const regeneratesTrackedMetrics = resolve(resolvedOutputPath) === resolve(trackedMetricsPath);
if (regeneratesTrackedMetrics && !hasProspectPipelineState) {
console.error(`Refusing to regenerate the tracked live metrics with a zero pipeline: no outbound prospect pipeline state found at ${prospectRoot}. Run this command from the service root that holds prospects/, or set SERVICE_REPO_ROOT to it, or pass an explicit --output= under runs/ for a private zero-state report.`);
process.exit(1);
}
if (!hasProspectPipelineState) {
console.warn(`Warning: no outbound prospect pipeline state found at ${prospectRoot}; pipeline counts in ${outputPath} will be zero.`);
}

function listFolders(root) {
if (root === "prospects" || root.endsWith("/prospects")) return listOutboundProspectFolders(root).filter((path) => !/(^|\/)(?:kit|import)-smoke/.test(path));
if (!existsSync(root)) return [];
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-internal-dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function readJson(path, fallback = {}) {
}

const doctor = runJson(["scripts/export-growth-doctor.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
let serviceQueue;
try { serviceQueue = runJson(["scripts/run-review-queue.mjs", "--dry-run", "--scope", "all"]); }
catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-market-benchmark.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function statusLabel(value) {
return "do not claim yet";
}

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
const sender = runJson(["scripts/check-outbound-sender-setup.mjs"]);
const proofRun = existsSync("prospects/loom-links.txt")
? runJson(["scripts/check-market-proof-run.mjs"])
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-market-learning-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function percent(numerator, denominator) {
return `${Math.round((numerator / denominator) * 100)}%`;
}

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
const proofCheck = existsSync("prospects/loom-links.txt")
? runJson(["scripts/check-market-proof-run.mjs"])
: { status: "missing-proof-run", sentProofRows: 0, readySendPackages: 0, validApprovedRows: 0, recommendedChannel: "unknown", senderWarnings: [] };
Expand Down
2 changes: 1 addition & 1 deletion scripts/export-market-proof-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ if (skipKit || !existsSync(outputPath)) parityArgs.push("--skip-kit");
const parity = runJson(parityArgs);
rmSync(parityOutputPath, { force: true });

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/live-metrics.md"]);
const channelGuidance = sendChannelGuidance();

const prospects = listFolders("prospects").map((path) => {
Expand Down
41 changes: 40 additions & 1 deletion scripts/test-active-operator-surfaces.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
}

function writeSurfaceProspect(slug, stage, scored) {
const dir = join(T, "prospects", slug)
writeJson(join(dir, "metadata.json"), {name: slug, slug, website: "https://example.com/surface", vertical: "managed-it-cybersecurity", contact: "Founder"})
writeJson(join(dir, "pipeline.json"), {stage, createdAt: "2026-08-01", sentAt: "", sentChannel: "", lastChannel: "", lastTouchAt: "", nextFollowUpAt: "", followUps: [], touches: [], notes: []})
if (scored) writeFileSync(join(dir, "lead-score.md"), "- Score: 14/16\n- Priority: record\n")
}

function hashTree(root) {
const hashes = {}
function visit(directory) {
Expand Down Expand Up @@ -68,8 +75,16 @@ try {
const trackedArtifacts = new Map(ACTIVE_OPERATOR_ARTIFACTS.map(path => [path, readFileSync(join(C, path))]))
for (const path of ACTIVE_OPERATOR_ARTIFACTS) rmSync(join(T, path), {force: true})
writeFileSync(join(T, "growth-brain/ops/11-10-proof-run.md"), "regeneration sentinel\n")
// The tracked live metrics surface must refuse to regenerate from a root
// without outbound prospect pipeline state instead of silently clobbering
// the tracked file with a zero pipeline. The remaining surfaces regenerate
// byte-identically from the hermetic empty root first.
const metricsRefusal = run(["scripts/export-growth-metrics.mjs"])
neq(metricsRefusal.status, 0, "State-less live metrics regeneration must refuse")
mat(metricsRefusal.stderr, /Refusing to regenerate the tracked live metrics with a zero pipeline/)
mat(metricsRefusal.stderr, /no outbound prospect pipeline state found at/)
eq(existsSync(join(T, "growth-brain/ops/live-metrics.md")), false, "Refused metrics export must not write the tracked surface")
for (const args of [
["scripts/export-growth-metrics.mjs"],
["scripts/export-market-proof-run.mjs"],
["scripts/check-market-proof-run.mjs"],
["scripts/export-sender-setup-guide.mjs"],
Expand All @@ -87,10 +102,29 @@ try {
const regenerated = run(args)
eq(regenerated.status, 0, regenerated.stderr || regenerated.stdout)
}
// Reproduce the tracked live metrics pipeline exactly: 38 new, 5 scored
// (active), and 7 paused (scored) prospect folders = 50 total with 12
// scored including inactive, plus 3 unregistered client folders, so the
// guarded tracked surface regenerates byte-identically through the real
// path. The fixture is inert for every other surface (no scores before
// this point are read by them, no touches, no Looms), so their tracked
// artifacts stay on the empty-root regeneration.
for (let index = 1; index <= 38; index++) writeSurfaceProspect(`surface-new-${index}`, "new", false)
for (let index = 1; index <= 5; index++) writeSurfaceProspect(`surface-scored-${index}`, "scored", true)
for (let index = 1; index <= 7; index++) writeSurfaceProspect(`surface-paused-${index}`, "paused", true)
for (let index = 1; index <= 3; index++) mkdirSync(join(T, "clients", `surface-blocked-${index}`), {recursive: true})
const metricsRegenerated = run(["scripts/export-growth-metrics.mjs"])
eq(metricsRegenerated.status, 0, metricsRegenerated.stderr || metricsRegenerated.stdout)
for (const [path, expected] of trackedArtifacts) {
eq(existsSync(join(T, path)), true, `Generator did not recreate ${path}`)
deq(readFileSync(join(T, path)), expected, `Tracked generated artifact is stale: ${path}`)
}
// Drop the pipeline reproduction fixture so the remaining assertions see
// the hermetic one-prospect state the imported application creates.
for (const prefix of ["surface-new-", "surface-scored-", "surface-paused-"]) {
for (let index = 1; index <= 50; index++) rmSync(join(T, "prospects", `${prefix}${index}`), {recursive: true, force: true})
}
for (let index = 1; index <= 3; index++) rmSync(join(T, "clients", `surface-blocked-${index}`), {recursive: true, force: true})
for (const path of privateRuntimeArtifacts) eq(existsSync(join(T, path)), true, `Private runtime artifact is missing: ${path}`)

const application = JSON.parse(readFileSync(join(T, "contracts/fixtures/sprint-application.v1.json"), "utf8"))
Expand Down Expand Up @@ -171,6 +205,11 @@ try {
dnm(readFileSync(join(T, artifact), "utf8"), retiredSurfacePattern)
}
const currentDate = trackedArtifactDate
// Nested operator surfaces only consume the metrics JSON and no longer
// refresh the tracked surface as a side effect, so regenerate it here for
// the state that exists at this point: the outbound fixture plus the
// unregistered unpaid client.
eq(run(["scripts/export-growth-metrics.mjs"]).status, 0)
const liveMetrics = readFileSync(join(T, "growth-brain/ops/live-metrics.md"), "utf8")
const growthDoctor = readFileSync(join(T, "runs/growth-doctor.md"), "utf8")
const ID = readFileSync(join(T, "runs/internal-dashboard.md"), "utf8")
Expand Down