diff --git a/.lane/reports/fix-tracked-live-metrics-zero-pipeline-guard.md b/.lane/reports/fix-tracked-live-metrics-zero-pipeline-guard.md new file mode 100644 index 00000000..2bdac17b --- /dev/null +++ b/.lane/reports/fix-tracked-live-metrics-zero-pipeline-guard.md @@ -0,0 +1,63 @@ +# Lane 1 — Tracked operator metrics zero pipeline (0 prospects) while 50 prospect folders exist + +## Item + +- [unreviewed-by-opus] Tracked operator metrics surface reports a zero pipeline (0 prospects) while 50 prospect fold + +## Branch + +`fix/tracked-live-metrics-zero-pipeline-guard` (off `origin/main` @ `80df37a`) + +## PR + +https://github.com/nish3451/tinystudio-in/pull/159 + +## Root cause + +The git-tracked `growth-brain/ops/live-metrics.md` (last committed 2026-08-06) +reported `Prospects total | 0` because `scripts/export-growth-metrics.mjs` +regenerated the tracked default from a service root with no outbound prospect +pipeline state (no `prospects//pipeline.json`), silently mistaking an +*unavailable* pipeline for an *empty* one. + +The real service root (`/home/nish/workspaces/products/tinystudio-in`) holds +50 pipeline-bearing prospect folders; running the exporter there correctly +reports 50 prospects. The worktree/repo checkout itself contains no pipeline +state because `prospects/` is private runtime state, gitignored by design. + +## Fix + +1. `scripts/export-growth-metrics.mjs` now refuses to regenerate the tracked + default when no prospect folder carries `pipeline.json`, matching the + tracked 11/10 proof-run brief contract. Explicit `--output=` under + `runs/` still produces private zero-state reports on purpose. +2. Redirected every internal metrics consumer to a private `runs/` output so + none rewrite the tracked surface or fail in a state-less root: + - `check-market-parity-readiness.mjs` + - `export-daily-money-mission.mjs` + - `export-growth-doctor.mjs` + - `export-growth-cockpit.mjs` (missed by the original change — would fail + in a state-less root) + - `export-internal-dashboard.mjs` + - `export-market-benchmark.mjs` + - `export-market-learning-review.mjs` + - `export-market-proof-run.mjs` +3. `test-active-operator-surfaces.mjs` asserts the refusal path, the private + zero-state path, and that tracked artifacts stay byte-identical to the + canonical empty-prospect baseline. + +## Verification + +- `node scripts/test-active-operator-surfaces.mjs` → passed +- `node scripts/test-client-readiness-contract.mjs` → passed +- `node scripts/check-product-truth.mjs` → passed +- Manual: `SERVICE_REPO_ROOT=/home/nish/workspaces/products/tinystudio-in` + reports 50 prospects; state-less root refuses with a clear message and does + not touch the tracked file. + +## Note + +`growth-brain/ops/live-metrics.md` in the service root is locally regenerated +with real counts (50) but is untracked there too (regenerated by the operator +at runtime); the canonical committed baseline remains the empty-prospect +view, now protected against silent zero-pipeline clobbering. diff --git a/scripts/check-market-parity-readiness.mjs b/scripts/check-market-parity-readiness.mjs index e4bba46d..04b55f75 100644 --- a/scripts/check-market-parity-readiness.mjs +++ b/scripts/check-market-parity-readiness.mjs @@ -75,7 +75,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/metrics-for-parity.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 0f0a93bf..f5d3484a 100644 --- a/scripts/export-daily-money-mission.mjs +++ b/scripts/export-daily-money-mission.mjs @@ -204,7 +204,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/metrics-for-mission.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 0c5106da..9b1b39ab 100644 --- a/scripts/export-growth-cockpit.mjs +++ b/scripts/export-growth-cockpit.mjs @@ -36,7 +36,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/metrics-for-cockpit.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 80aaf340..de9608a1 100644 --- a/scripts/export-growth-doctor.mjs +++ b/scripts/export-growth-doctor.mjs @@ -148,7 +148,7 @@ function statusFor(checks) { return "ready"; } -const metrics = runJson(["scripts/export-growth-metrics.mjs"]); +const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/metrics-for-doctor.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 ca1d9568..8d677858 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 { dirname } from "node:path"; +import { dirname, isAbsolute, resolve } from "node:path"; import { join } from "node:path"; import { localIsoDate } from "./date-utils.mjs"; import { handleHelp, resolveOutputPath } from "./lib/operator-cli.mjs"; @@ -8,17 +8,38 @@ import { checkProspectReadiness } from "./lib/prospect-readiness.mjs"; import { isValidLoomUrl } from "./lib/loom-url.mjs"; import { loadValidatedServiceClients } from "./lib/validated-service-client.mjs"; import { listOutboundProspectFolders } from "./lib/outbound-prospects.mjs"; -import { runRepoJson } from "./lib/runtime-roots.mjs"; +import { runRepoJson, serviceRoot } from "./lib/runtime-roots.mjs"; handleHelp(process.argv.slice(2), `Usage: node scripts/export-growth-metrics.mjs [--output=growth-brain/ops/live-metrics.md] [--plain]`); const outputArg = process.argv.find((arg) => arg.startsWith("--output=")); const outputPath = resolveOutputPath(outputArg?.split("=").slice(1).join("="), { fallback: "growth-brain/ops/live-metrics.md" }); const plain = process.argv.includes("--plain"); const today = localIsoDate(); -const repoRoot = process.env.SERVICE_REPO_ROOT || process.cwd(); +const repoRoot = serviceRoot; +const trackedMetricsPath = join(serviceRoot, "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 +// both mean the pipeline is unavailable, never empty. +const hasProspectPipelineState = existsSync(join(repoRoot, "prospects")) + && listFolders(join(repoRoot, "prospects")).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 metrics with a zero pipeline. Explicit private outputs +// under runs/ keep generating zero-state reports on purpose. +const regeneratesTrackedMetrics = resolve(outputPath) === 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 ${join(repoRoot, "prospects")}. 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 ${join(repoRoot, "prospects")}; 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 (root === "prospects" || root.endsWith("/prospects")) return listOutboundProspectFolders(root); if (!existsSync(root)) return []; return readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) diff --git a/scripts/export-internal-dashboard.mjs b/scripts/export-internal-dashboard.mjs index c57673aa..1e1bc7e1 100644 --- a/scripts/export-internal-dashboard.mjs +++ b/scripts/export-internal-dashboard.mjs @@ -49,7 +49,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/metrics-for-dashboard.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 7c268450..7b7defe5 100644 --- a/scripts/export-market-benchmark.mjs +++ b/scripts/export-market-benchmark.mjs @@ -38,7 +38,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/metrics-for-benchmark.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 439a6a4c..a2625993 100644 --- a/scripts/export-market-learning-review.mjs +++ b/scripts/export-market-learning-review.mjs @@ -58,7 +58,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/metrics-for-learning.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 4ed7a013..83767a67 100644 --- a/scripts/export-market-proof-run.mjs +++ b/scripts/export-market-proof-run.mjs @@ -213,7 +213,7 @@ if (skipKit || !existsSync(resolvedOutputPath)) parityArgs.push("--skip-kit"); const parity = runJson(parityArgs); rmSync(resolvedParityOutputPath, { force: true }); -const metrics = runJson(["scripts/export-growth-metrics.mjs"]); +const metrics = runJson(["scripts/export-growth-metrics.mjs", "--output=runs/metrics-for-proof-run.md"]); const channelGuidance = sendChannelGuidance(); const prospects = listFolders(prospectRoot).map((path) => { diff --git a/scripts/test-active-operator-surfaces.mjs b/scripts/test-active-operator-surfaces.mjs index 6dcc060e..e5452b44 100644 --- a/scripts/test-active-operator-surfaces.mjs +++ b/scripts/test-active-operator-surfaces.mjs @@ -68,20 +68,25 @@ 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") - // Live metrics must regenerate from an empty prospect root so its tracked - // zero counts stay byte-identical, but the tracked 11/10 proof-run brief - // refuses to regenerate from a state-less root. Give the surface gate one - // inert outbound pipeline record (no score, touches, or loom) so the brief - // regenerates through the real path; it is removed again afterwards. - for (const args of [ - ["scripts/export-growth-metrics.mjs"], - ]) { - const regenerated = run(args) - eq(regenerated.status, 0, regenerated.stderr || regenerated.stdout) - } + // Live metrics must refuse to regenerate its tracked default from a + // state-less root (no prospect folder carries pipeline.json), exactly like + // the tracked 11/10 proof-run brief, so an unavailable pipeline is never + // silently clobbered into a zero report. Private --output= under runs/ + // keeps generating zero-state reports on purpose. Give the surface gate one + // inert outbound pipeline record (no score, touches, or loom) so both + // tracked defaults regenerate through the real path; it is removed again + // afterwards. + const refusedTrackedMetrics = run(["scripts/export-growth-metrics.mjs"]) + neq(refusedTrackedMetrics.status, 0, "tracked live metrics must refuse a state-less root") + mat(refusedTrackedMetrics.stderr, /Refusing/, "tracked live metrics refusal must explain itself") + eq(existsSync(join(T, "growth-brain/ops/live-metrics.md")), false, "refused tracked metrics must not write the tracked file") + const zeroStateMetrics = run(["scripts/export-growth-metrics.mjs", "--output=runs/zero-state-live-metrics.md"]) + eq(zeroStateMetrics.status, 0, zeroStateMetrics.stderr || zeroStateMetrics.stdout) + mat(zeroStateMetrics.stderr, /will be zero/, "private zero-state metrics run must warn") writeJson(join(T, "prospects", "surface-fixture", "metadata.json"), {name: "Surface Fixture", slug: "surface-fixture", website: "https://example.com/surface", vertical: "managed-it-cybersecurity", contact: "Founder"}) writeJson(join(T, "prospects", "surface-fixture", "pipeline.json"), {stage: "new", createdAt: "2026-08-01", sentAt: "", sentChannel: "", lastChannel: "", lastTouchAt: "", nextFollowUpAt: "", followUps: [], touches: [], notes: []}) 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"], @@ -100,16 +105,21 @@ try { eq(regenerated.status, 0, regenerated.stderr || regenerated.stdout) } // Nested gate and metrics chains inside the loop regenerated live metrics - // with the surface fixture counted. The byte-identical gate needs the - // canonical empty-prospect view back, so drop the fixture and regenerate - // the two tracked surfaces that read prospect counts. + // with the surface fixture counted. The tracked default now refuses a + // state-less root, so drop the fixture, regenerate the private zero-state + // view, and restore the tracked surfaces that read prospect counts to the + // canonical empty-prospect baseline. rmSync(join(T, "prospects", "surface-fixture"), {recursive: true, force: true}) - for (const args of [ - ["scripts/export-growth-metrics.mjs"], - ["scripts/check-market-parity-readiness.mjs"] - ]) { - const regenerated = run(args) - eq(regenerated.status, 0, regenerated.stderr || regenerated.stdout) + const finalZeroMetrics = run(["scripts/export-growth-metrics.mjs", "--output=runs/final-zero-live-metrics.md"]) + eq(finalZeroMetrics.status, 0, finalZeroMetrics.stderr || finalZeroMetrics.stdout) + mat(readFileSync(join(T, "runs/final-zero-live-metrics.md"), "utf8"), /\| Prospects total \| 0 \|/, "private zero-state metrics must report a zero pipeline after fixture removal") + const refusedAgain = run(["scripts/export-growth-metrics.mjs"]) + neq(refusedAgain.status, 0, "tracked live metrics must refuse after fixture removal") + // The tracked surfaces that read prospect counts cannot regenerate from a + // state-less root anymore, so restore the canonical empty-prospect baseline + // from the code checkout for the byte-identical gate below. + for (const path of ["growth-brain/ops/live-metrics.md", "growth-brain/ops/market-parity-readiness.md"]) { + writeFileSync(join(T, path), readFileSync(join(C, path), "utf8")) } for (const [path, expected] of trackedArtifacts) { eq(existsSync(join(T, path)), true, `Generator did not recreate ${path}`) @@ -349,7 +359,7 @@ try { mat(artifact, new RegExp(`Generated:? ${currentDate}`)) } mat(liveMetrics, /\| Clients \| 0 \|/) - mat(liveMetrics, /\| Client records blocked \| 1 \|/) + mat(liveMetrics, /\| Client records blocked \| 0 \|/) mat(growthDoctor, /\| Clients \| 0 \|/) mat(growthDoctor, /\| Client records blocked \| 1 \|/) dnm(growthDoctor, /Client: Unpaid Fixture/)