Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c5e812b
fix(ops): guard tracked live metrics against zero-pipeline clobbering
nish3451 Aug 14, 2026
44820b7
docs(lane1): report tracked live-metrics zero-pipeline fix (#159)
nish3451 Aug 14, 2026
95cdab4
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
346bfda
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
5c43cda
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
c350a4c
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
e8ec7be
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
a97e059
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 14, 2026
e47e576
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
8a873a0
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
3c8753b
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
9eed089
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
538d388
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
e4993a3
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
c495d25
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
29d377a
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
b2c9b87
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
74e130a
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
4e617a5
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
8a8f489
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
509a782
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
1bce7fb
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
7fd727d
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 15, 2026
e5391d0
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
ea167e2
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
74932c6
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
04a88ef
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
cf33c31
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
41bd535
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
5aae0c4
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 17, 2026
a3dc1eb
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 18, 2026
fb8d155
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 19, 2026
5732fea
Merge branch 'main' into fix/tracked-live-metrics-zero-pipeline-guard
nish3451 Aug 19, 2026
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
63 changes: 63 additions & 0 deletions .lane/reports/fix-tracked-live-metrics-zero-pipeline-guard.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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.
2 changes: 1 addition & 1 deletion scripts/check-market-parity-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
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 @@ -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)}`]);
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 @@ -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"]);
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 @@ -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"]);
Expand Down
29 changes: 25 additions & 4 deletions scripts/export-growth-metrics.mjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
#!/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";
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())
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 @@ -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) {
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 @@ -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"])
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 @@ -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: [] };
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 @@ -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) => {
Expand Down
52 changes: 31 additions & 21 deletions scripts/test-active-operator-surfaces.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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}`)
Expand Down Expand Up @@ -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/)
Expand Down