feat(growth): decision log for learning (#3187)
Some checks are pending
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

Adds decision logging to track approved/edited/skipped Reddit growth candidates. The log feeds back into the Claude prompt to improve future candidate selection based on past patterns.
This commit is contained in:
A 2026-04-05 17:38:30 -07:00 committed by GitHub
parent 0ece17d92e
commit d42cbca525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 6 deletions

View file

@ -6,6 +6,14 @@ Your job: from the pre-fetched Reddit posts below, find the ONE best thread wher
**IMPORTANT: Do NOT use any tools.** All data is provided below. Your entire response should be plain text output — no bash commands, no file reads, no tool calls. Just analyze the data and respond with your findings.
## Past decisions
The team has reviewed previous candidates. Learn from these patterns — what got approved, what got skipped, and how replies were edited. Prefer posts similar to approved ones and avoid patterns seen in skipped ones.
```
DECISIONS_PLACEHOLDER
```
## Pre-fetched Reddit data
The following posts were fetched automatically. Each post includes the title, selftext, subreddit, engagement stats, and the poster's recent comment history.

View file

@ -82,10 +82,16 @@ fi
# Inject Reddit data into prompt template
REDDIT_JSON=$(cat "${REDDIT_DATA_FILE}")
# Use bun for safe substitution to avoid sed escaping issues with JSON
DECISIONS_FILE="${HOME}/.config/spawn/growth-decisions.md"
bun -e "
import { existsSync } from 'node:fs';
const template = await Bun.file('${PROMPT_TEMPLATE}').text();
const data = await Bun.file('${REDDIT_DATA_FILE}').text();
const result = template.replace('REDDIT_DATA_PLACEHOLDER', data.trim());
const decisionsPath = '${DECISIONS_FILE}';
const decisions = existsSync(decisionsPath) ? await Bun.file(decisionsPath).text() : 'No past decisions yet.';
const result = template
.replace('REDDIT_DATA_PLACEHOLDER', data.trim())
.replace('DECISIONS_PLACEHOLDER', decisions.trim());
await Bun.write('${PROMPT_FILE}', result);
"

View file

@ -5,7 +5,16 @@ import type { Result } from "@openrouter/spawn-shared";
import type { Block } from "@slack/bolt";
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import {
appendFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
import { Err, isString, Ok, toRecord } from "@openrouter/spawn-shared";
import { slackifyMarkdown } from "slackify-markdown";
@ -299,9 +308,10 @@ function rowToCandidate(r: RawCandidate): CandidateRow {
draftReply: r.draft_reply,
slackChannel: r.slack_channel ?? undefined,
slackTs: r.slack_ts ?? undefined,
status: r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error"
? r.status
: "pending",
status:
r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error"
? r.status
: "pending",
actionedBy: r.actioned_by ?? undefined,
actionedAt: r.actioned_at ?? undefined,
postedReply: r.posted_reply ?? undefined,
@ -335,7 +345,12 @@ export function upsertCandidate(db: Database, candidate: CandidateRow): void {
/** Look up a candidate by Reddit post ID. */
export function findCandidate(db: Database, postId: string): CandidateRow | undefined {
const row = db
.query<RawCandidate, [string]>("SELECT * FROM candidates WHERE post_id = ?")
.query<
RawCandidate,
[
string,
]
>("SELECT * FROM candidates WHERE post_id = ?")
.get(postId);
return row ? rowToCandidate(row) : undefined;
}
@ -370,6 +385,43 @@ export function updateCandidateStatus(
);
}
const DECISIONS_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/growth-decisions.md`;
/** Append a decision entry to the growth decisions log. */
export function logDecision(
candidate: CandidateRow,
decision: "approved" | "edited" | "skipped",
editedReply?: string,
): void {
const dir = dirname(DECISIONS_PATH);
if (!existsSync(dir))
mkdirSync(dir, {
recursive: true,
});
const date = new Date().toISOString().split("T")[0];
const reply = editedReply ?? candidate.draftReply;
const entry = `
## ${decision.toUpperCase()} ${date}
- **Post**: [${candidate.title}](https://reddit.com${candidate.permalink})
- **Subreddit**: r/${candidate.subreddit}
- **Decision**: ${decision}
${editedReply ? "- **Edited**: yes (original draft was modified)\n" : ""}\
- **Reply**: ${reply.replace(/\n/g, " ")}
---
`;
appendFileSync(DECISIONS_PATH, entry);
}
/** Read the decisions log (returns empty string if no file). */
export function readDecisions(): string {
if (!existsSync(DECISIONS_PATH)) return "";
return readFileSync(DECISIONS_PATH, "utf-8");
}
// #endregion
// #region Claude Code stream parsing

View file

@ -14,12 +14,14 @@ import {
findCandidate,
findThread,
formatToolStats,
logDecision,
markdownToRichTextBlocks,
openDb,
PR_URL_REGEX,
parseStreamEvent,
plainTextFallback,
ResultSchema,
readDecisions,
runCleanupIfDue,
stripMention,
updateCandidateStatus,
@ -1042,6 +1044,7 @@ app.action("growth_approve", async ({ ack, body, client }) => {
postedReply: candidate.draftReply,
redditCommentUrl: commentUrl,
});
logDecision(candidate, "approved");
// Update the Slack message — replace buttons with confirmation
if (candidate.slackChannel && candidate.slackTs) {
await replaceButtonsWithStatus(
@ -1184,6 +1187,7 @@ app.view("growth_edit_submit", async ({ ack, view, body, client }) => {
postedReply: editedReply,
redditCommentUrl: commentUrl,
});
logDecision(candidate, "edited", editedReply);
if (candidate.slackChannel && candidate.slackTs) {
await replaceButtonsWithStatus(
client,
@ -1230,6 +1234,7 @@ app.action("growth_skip", async ({ ack, body, client }) => {
status: "skipped",
actionedBy: userId,
});
logDecision(candidate, "skipped");
if (candidate.slackChannel && candidate.slackTs) {
await replaceButtonsWithStatus(