diff --git a/growth-brain/ops/live-metrics.md b/growth-brain/ops/live-metrics.md index 9c96e14c..9bcaae01 100644 --- a/growth-brain/ops/live-metrics.md +++ b/growth-brain/ops/live-metrics.md @@ -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 | @@ -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 @@ -38,7 +38,9 @@ Generated: 2026-08-06 | Stage | Count | |---|---:| - +| new | 38 | +| paused | 7 | +| scored | 5 | ## Decision Rule diff --git a/scripts/check-market-parity-readiness.mjs b/scripts/check-market-parity-readiness.mjs index e693d54f..cb5a79ae 100644 --- a/scripts/check-market-parity-readiness.mjs +++ b/scripts/check-market-parity-readiness.mjs @@ -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 } diff --git a/scripts/export-daily-money-mission.mjs b/scripts/export-daily-money-mission.mjs index 70cc372c..1c6d1958 100644 --- a/scripts/export-daily-money-mission.mjs +++ b/scripts/export-daily-money-mission.mjs @@ -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)}`]); diff --git a/scripts/export-growth-cockpit.mjs b/scripts/export-growth-cockpit.mjs index bf43b731..2f353f73 100644 --- a/scripts/export-growth-cockpit.mjs +++ b/scripts/export-growth-cockpit.mjs @@ -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"]); runJson(["scripts/export-proof-library.mjs"]); runJson(["scripts/export-managed-it-one-pager.mjs"]); runJson(["scripts/export-growth-doctor.mjs", "--no-checks"]); diff --git a/scripts/export-growth-doctor.mjs b/scripts/export-growth-doctor.mjs index b09a61cd..580e5fe7 100644 --- a/scripts/export-growth-doctor.mjs +++ b/scripts/export-growth-doctor.mjs @@ -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"]); diff --git a/scripts/export-growth-metrics.mjs b/scripts/export-growth-metrics.mjs index 9b7cabbc..645e4cc5 100644 --- a/scripts/export-growth-metrics.mjs +++ b/scripts/export-growth-metrics.mjs @@ -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"; @@ -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"); + +// 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 []; diff --git a/scripts/export-internal-dashboard.mjs b/scripts/export-internal-dashboard.mjs index 1f57d9ce..91f83c06 100644 --- a/scripts/export-internal-dashboard.mjs +++ b/scripts/export-internal-dashboard.mjs @@ -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) { diff --git a/scripts/export-market-benchmark.mjs b/scripts/export-market-benchmark.mjs index e450a150..6a3036a1 100644 --- a/scripts/export-market-benchmark.mjs +++ b/scripts/export-market-benchmark.mjs @@ -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"]) diff --git a/scripts/export-market-learning-review.mjs b/scripts/export-market-learning-review.mjs index de00a568..450baa1d 100644 --- a/scripts/export-market-learning-review.mjs +++ b/scripts/export-market-learning-review.mjs @@ -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: [] }; diff --git a/scripts/export-market-proof-run.mjs b/scripts/export-market-proof-run.mjs index 77af99a2..20bdb5c5 100644 --- a/scripts/export-market-proof-run.mjs +++ b/scripts/export-market-proof-run.mjs @@ -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) => { diff --git a/scripts/test-active-operator-surfaces.mjs b/scripts/test-active-operator-surfaces.mjs index f727d1a5..c22c6a84 100644 --- a/scripts/test-active-operator-surfaces.mjs +++ b/scripts/test-active-operator-surfaces.mjs @@ -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) { @@ -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"], @@ -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")) @@ -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")