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
2 changes: 1 addition & 1 deletion growth-brain/ops/11-10-proof-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ npm run prospect:batch-sent -- --from-clipboard

## Direction Proof Gate

Source: the direction dossier gates this proof run on 5 approved Looms (recorded, then sent) and 40 qualified personalized touches. Counters read only existing repository state: approval rows and recorded Loom URLs in `prospects/loom-links.txt`, and pipeline `touches`/`sentAt`/notes under `prospects/<slug>/`. Drafts, raw LOOM_URL placeholders, unapproved rows, and prospects without touch evidence never count.
Source: the direction dossier gates this proof run on 5 approved Looms (recorded, then sent) and 40 qualified personalized touches. Counters read only existing repository state: approval rows and recorded Loom URLs in `prospects/loom-links.txt`, and pipeline `touches`/`sentAt`/notes under `prospects/<slug>/`. Drafts, raw LOOM_URL placeholders, unapproved rows, and prospects without touch evidence never count. Regeneration refuses to overwrite this tracked brief when the service root holds no prospect pipeline state, so an unavailable pipeline is never mistaken for an empty one.

| Gate | Progress | Counted Evidence |
|---|---:|---|
Expand Down
52 changes: 40 additions & 12 deletions scripts/export-market-proof-run.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { isAbsolute, join, relative, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { localIsoDate } from "./date-utils.mjs";
import { checkProspectReadiness, prospectWarningWeight } from "./lib/prospect-readiness.mjs";
Expand All @@ -9,6 +9,7 @@ import { sendChannelGuidance } from "./lib/send-channel-guidance.mjs";
import { routedContactPlan } from "./lib/contact-route.mjs";
import { canonicalProspectAsk } from "./lib/canonical-service-copy.mjs";
import { listOutboundProspectFolders } from "./lib/outbound-prospects.mjs";
import { serviceRoot } from "./lib/runtime-roots.mjs";

const outputArg = process.argv.find((arg) => arg.startsWith("--output="));
const outputPath = outputArg ? outputArg.split("=")[1] : "growth-brain/ops/11-10-proof-run.md";
Expand All @@ -23,8 +24,31 @@ const loomLinksPath = loomLinksArg
? "prospects/kit-proof-run-loom-links.txt"
: "prospects/loom-links.txt";

// Every read and write is anchored to the service root (SERVICE_REPO_ROOT or
// the invocation directory) so a brief regenerated from any working directory
// reports the same pipeline state the rest of the operator surfaces read.
const resolvedOutputPath = isAbsolute(outputPath) ? outputPath : join(serviceRoot, outputPath);
const resolvedLoomLinksPath = isAbsolute(loomLinksPath) ? loomLinksPath : join(serviceRoot, loomLinksPath);
const prospectRoot = join(serviceRoot, "prospects");
const trackedBriefPath = join(serviceRoot, "growth-brain/ops/11-10-proof-run.md");

// The default output is a git-tracked operator surface. When the service root
// holds no outbound prospect state, regeneration cannot tell an empty pipeline
// from an unavailable one, so it refuses instead of silently clobbering the
// tracked brief with a zero pipeline. Explicit private outputs under runs/
// keep generating zero-state reports on purpose.
const regeneratesTrackedBrief = resolve(resolvedOutputPath) === resolve(trackedBriefPath);
if (regeneratesTrackedBrief && !existsSync(prospectRoot)) {
Comment on lines +40 to +41

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 Refuse regeneration when the prospect root is empty

This guard only tests whether prospects/ exists, not whether it contains any outbound pipeline state. A private zero-state run using the suggested --output=runs/... path and the default Loom sheet creates prospects/loom-links.txt; a subsequent default run then bypasses this check and can overwrite the tracked brief with zero counts despite still having no prospect records, recreating the exact silent-clobber scenario this change is intended to prevent.

Useful? React with 👍 / 👎.

console.error(`Refusing to regenerate the tracked 11/10 proof-run brief with a zero pipeline: no outbound prospect 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 (!existsSync(prospectRoot)) {
console.warn(`Warning: no outbound prospect state found at ${prospectRoot}; pipeline counts in ${outputPath} will be zero.`);
Comment on lines +41 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Check actual prospect pipeline state before allowing tracked regeneration.

existsSync(prospectRoot) is true for an empty prospects/ directory. It is also true after an explicit private report creates prospects/loom-links.txt at Line 371. A private zero-state run followed by a default run can therefore overwrite the tracked brief with zero counts.

Require at least one outbound prospect folder with pipeline.json. Use the same predicate for the refusal and warning. Extend the test after Line 147 to run the default output after the private output and assert that it still refuses.

Proposed fix
 const regeneratesTrackedBrief = resolve(resolvedOutputPath) === resolve(trackedBriefPath);
-if (regeneratesTrackedBrief && !existsSync(prospectRoot)) {
+const hasProspectPipelineState = existsSync(prospectRoot)
+  && listFolders(prospectRoot).some((path) => existsSync(join(path, "pipeline.json")));
+
+if (regeneratesTrackedBrief && !hasProspectPipelineState) {
   console.error(`Refusing to regenerate the tracked 11/10 proof-run brief with a zero pipeline: no outbound prospect 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 (!existsSync(prospectRoot)) {
+if (!hasProspectPipelineState) {
   console.warn(`Warning: no outbound prospect state found at ${prospectRoot}; pipeline counts in ${outputPath} will be zero.`);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (regeneratesTrackedBrief && !existsSync(prospectRoot)) {
console.error(`Refusing to regenerate the tracked 11/10 proof-run brief with a zero pipeline: no outbound prospect 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 (!existsSync(prospectRoot)) {
console.warn(`Warning: no outbound prospect state found at ${prospectRoot}; pipeline counts in ${outputPath} will be zero.`);
const hasProspectPipelineState = existsSync(prospectRoot)
&& listFolders(prospectRoot).some((path) => existsSync(join(path, "pipeline.json")));
if (regeneratesTrackedBrief && !hasProspectPipelineState) {
console.error(`Refusing to regenerate the tracked 11/10 proof-run brief with a zero pipeline: no outbound prospect 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 state found at ${prospectRoot}; pipeline counts in ${outputPath} will be zero.`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/export-market-proof-run.mjs` around lines 41 - 46, Replace the
existsSync(prospectRoot) checks in the tracked-regeneration refusal and warning
branches with a shared predicate that confirms at least one outbound prospect
folder contains pipeline.json. Reuse this predicate for both branches,
preserving the existing refusal and warning behavior. Extend the relevant test
after the private-output case to run the default output and assert it still
refuses.

}

function runJson(args) {
const output = execFileSync("node", args, {
cwd: serviceRoot,
Comment on lines 50 to +51

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 Resolve child scripts from the code checkout

When SERVICE_REPO_ROOT points to a supported data-only service root, setting cwd here makes relative arguments such as scripts/check-market-parity-readiness.mjs resolve inside that data root. Because it has no scripts/ directory, market:proof-run exits with MODULE_NOT_FOUND before generating anything; resolve child entrypoints against the code checkout as runtime-roots.mjs does while retaining serviceRoot as their working/data directory.

Useful? React with 👍 / 👎.

encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"]
});
Expand Down Expand Up @@ -178,15 +202,16 @@ function directionGateCounts(loomRows, prospectRows) {
const parityOutputPath = outputPath.startsWith("prospects/kit-")
? "prospects/kit-market-proof-run-parity.md"
: "prospects/market-proof-run-parity.md";
const resolvedParityOutputPath = isAbsolute(parityOutputPath) ? parityOutputPath : join(serviceRoot, parityOutputPath);
const parityArgs = ["scripts/check-market-parity-readiness.mjs", `--output=${parityOutputPath}`];
if (skipKit || !existsSync(outputPath)) parityArgs.push("--skip-kit");
if (skipKit || !existsSync(resolvedOutputPath)) parityArgs.push("--skip-kit");
const parity = runJson(parityArgs);
rmSync(parityOutputPath, { force: true });
rmSync(resolvedParityOutputPath, { force: true });

const metrics = runJson(["scripts/export-growth-metrics.mjs"]);
const channelGuidance = sendChannelGuidance();

const prospects = listFolders("prospects").map((path) => {
const prospects = listFolders(prospectRoot).map((path) => {
const metadata = json(join(path, "metadata.json"));
const pipeline = json(join(path, "pipeline.json"));
const readiness = checkProspectReadiness(path);
Expand Down Expand Up @@ -226,7 +251,10 @@ const recordingBatch = prospects
.slice(0, limit);

const directionGate = directionGateCounts(
read(loomLinksPath).split("\n").map(parseLoomSheetLine).filter(Boolean),
read(resolvedLoomLinksPath).split("\n").map(parseLoomSheetLine).filter(Boolean).map((row) => ({
...row,
path: row.path && (isAbsolute(row.path) ? row.path : join(serviceRoot, row.path))
})),
prospects
);

Expand All @@ -243,7 +271,7 @@ const blockerRows = parity.blockers.length
: "| - | - | No blockers. |";

const loomSheetRows = recordingBatch.length
? recordingBatch.map((prospect) => `${prospect.path}|LOOM_URL|approved|${cleanSheetNote(prospect.fault, "specific visible fault")}|${cleanSheetNote(prospect.impact, "buyer impact from the recording")}|${cleanSheetNote(prospect.fix, "first fix shown in the recording")}|${cleanSheetNote(prospect.ask, "ask if they want the sprint plan")}`).join("\n")
? recordingBatch.map((prospect) => `${relative(serviceRoot, prospect.path)}|LOOM_URL|approved|${cleanSheetNote(prospect.fault, "specific visible fault")}|${cleanSheetNote(prospect.impact, "buyer impact from the recording")}|${cleanSheetNote(prospect.fix, "first fix shown in the recording")}|${cleanSheetNote(prospect.ask, "ask if they want the sprint plan")}`).join("\n")
: "prospects/prospect-slug|https://www.loom.com/share/...|approved|specific fault|buyer impact|first fix|clean ask";

const markdown = `# 11/10 Proof Run
Expand Down Expand Up @@ -321,7 +349,7 @@ npm run prospect:batch-sent -- --from-clipboard

## Direction Proof Gate

Source: the direction dossier gates this proof run on 5 approved Looms (recorded, then sent) and 40 qualified personalized touches. Counters read only existing repository state: approval rows and recorded Loom URLs in \`${loomLinksPath}\`, and pipeline \`touches\`/\`sentAt\`/notes under \`prospects/<slug>/\`. Drafts, raw LOOM_URL placeholders, unapproved rows, and prospects without touch evidence never count.
Source: the direction dossier gates this proof run on 5 approved Looms (recorded, then sent) and 40 qualified personalized touches. Counters read only existing repository state: approval rows and recorded Loom URLs in \`${loomLinksPath}\`, and pipeline \`touches\`/\`sentAt\`/notes under \`prospects/<slug>/\`. Drafts, raw LOOM_URL placeholders, unapproved rows, and prospects without touch evidence never count. Regeneration refuses to overwrite this tracked brief when the service root holds no prospect pipeline state, so an unavailable pipeline is never mistaken for an empty one.

| Gate | Progress | Counted Evidence |
|---|---:|---|
Expand All @@ -335,17 +363,17 @@ Source: the direction dossier gates this proof run on 5 approved Looms (recorded
- Missing: ${40 - directionGate.qualifiedTouches} qualified touch(es) are still absent; ${directionGate.qualifiedProspectsWithTouches} qualified prospect(s) currently carry touch evidence.
`;

const outputDir = outputPath.split("/").slice(0, -1).join("/");
const outputDir = resolvedOutputPath.split("/").slice(0, -1).join("/");
if (outputDir) mkdirSync(outputDir, { recursive: true });
writeFileSync(outputPath, markdown);
writeFileSync(resolvedOutputPath, markdown);

const loomLinksDir = loomLinksPath.split("/").slice(0, -1).join("/");
const loomLinksDir = resolvedLoomLinksPath.split("/").slice(0, -1).join("/");
if (loomLinksDir) mkdirSync(loomLinksDir, { recursive: true });
const existingLoomRows = read(loomLinksPath).split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
const existingLoomRows = read(resolvedLoomLinksPath).split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
const existingLoomPaths = new Set(existingLoomRows.map((line) => line.split("|")[0].trim()));
const generatedLoomRows = recordingBatch.length ? loomSheetRows.split("\n").filter((line) => !existingLoomPaths.has(line.split("|")[0].trim())) : existingLoomRows.length ? [] : [loomSheetRows];
const mergedLoomRows = [...existingLoomRows, ...generatedLoomRows];
writeFileSync(loomLinksPath, `# Replace LOOM_URL with each real Loom share link, or run: npm run market:after-recording -- --from-clipboard\n# Fast format after recording: paste either URL-only lines in this exact order, or prospects/prospect-slug|https://www.loom.com/share/...\n# Full format still works: prospects/prospect-slug|https://www.loom.com/share/...|approved|fault note|impact note|fix note|ask note\n\n${mergedLoomRows.join("\n")}\n`);
writeFileSync(resolvedLoomLinksPath, `# Replace LOOM_URL with each real Loom share link, or run: npm run market:after-recording -- --from-clipboard\n# Fast format after recording: paste either URL-only lines in this exact order, or prospects/prospect-slug|https://www.loom.com/share/...\n# Full format still works: prospects/prospect-slug|https://www.loom.com/share/...|approved|fault note|impact note|fix note|ask note\n\n${mergedLoomRows.join("\n")}\n`);

console.log(JSON.stringify({
status: "created",
Expand Down
20 changes: 18 additions & 2 deletions scripts/test-direction-proof-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ function fixedEnv() {
return {...process.env, NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${fixedClockImport}`].filter(Boolean).join(" "), SERVICE_REPO_ROOT: T, SERVICE_TEST_NOW: `${trackedArtifactDate}T12:00:00.000+05:30`, TZ: "Asia/Kolkata"}
}

function run(args) {
return spawnSync(process.execPath, args, {cwd: T, encoding: "utf8", env: fixedEnv()})
function run(args, cwd = T) {
return spawnSync(process.execPath, args, {cwd, encoding: "utf8", env: fixedEnv()})
}

function writeJson(path, value) {
Expand Down Expand Up @@ -132,6 +132,22 @@ try {
eq(noProspectsRun.status, 0, noProspectsRun.stderr || noProspectsRun.stdout)
deq(JSON.parse(noProspectsRun.stdout).directionGate.qualifiedTouches, 0, "no prospects means no qualified touches")
mat(readFileSync(join(T, "runs/no-prospects-proof-gate.md"), "utf8"), /\| Qualified touches \| 0\/40 \|/)

rmSync(join(T, "prospects"), {recursive: true, force: true})
const trackedBriefPath = join(T, "growth-brain/ops/11-10-proof-run.md")
const trackedBriefBefore = readFileSync(trackedBriefPath, "utf8")
const refusedRun = run(["scripts/export-market-proof-run.mjs", "--skip-kit"])
ok(refusedRun.status !== 0, "regenerating the tracked brief without prospect state must refuse instead of silently reporting a zero pipeline")
mat(refusedRun.stderr, /Refusing to regenerate the tracked 11\/10 proof-run brief with a zero pipeline/)
eq(readFileSync(trackedBriefPath, "utf8"), trackedBriefBefore, "refused regeneration must leave the tracked brief untouched")
eq(existsSync(join(T, "prospects")), false, "refused regeneration must not create a prospect root or loom sheet")

const anchoredCwd = join(T, "runner-cwd")
mkdirSync(anchoredCwd, { recursive: true })
const anchoredRun = run([join(C, "scripts/export-market-proof-run.mjs"), "--skip-kit", "--output=runs/rooted-proof-gate.md"], anchoredCwd)
eq(anchoredRun.status, 0, anchoredRun.stderr || anchoredRun.stdout)
ok(existsSync(join(T, "runs/rooted-proof-gate.md")), "anchored generation must write into the service root")
eq(existsSync(join(anchoredCwd, "runs/rooted-proof-gate.md")), false, "anchored generation must not write into the invocation directory")
console.log("Direction proof gate checks passed.")
} finally {
rmSync(T, {recursive: true, force: true})
Expand Down