← back to Slack Idea Board
slack-idea-board: fix stale aa_fit->good_build comment + serialize launchClaude to close the double-launch window race (captures in-flight good_build enrich refactor)
b9fa503d2095c3968614d969b6dcaa3f1f2c3e3c · 2026-09-15 18:16:40 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pnon7dut9MfPekhhm9YY4Q
Files touched
Diff
commit b9fa503d2095c3968614d969b6dcaa3f1f2c3e3c
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Sep 15 18:16:40 2026 -0700
slack-idea-board: fix stale aa_fit->good_build comment + serialize launchClaude to close the double-launch window race (captures in-flight good_build enrich refactor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pnon7dut9MfPekhhm9YY4Q
---
server.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 102 insertions(+), 33 deletions(-)
diff --git a/server.js b/server.js
index efef8e0..a5e2d1a 100644
--- a/server.js
+++ b/server.js
@@ -14,11 +14,13 @@ const DIR = __dirname;
const ACCEL_DIR = process.env.ACCEL_DIR || path.join(os.homedir(), 'Projects', 'claude-webdev-accelerator');
const ENV = loadEnv('/Users/macstudio3/Projects/slack-to-steve/.env'); // reuse the bot token + channels
const TOKEN = ENV.SLACK_BOT_TOKEN;
-const CHANNELS = (ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || '').split(',').map(s => s.trim()).filter(Boolean);
+// Steve's ask (2026-08-30): pull the EXACT posts from the #claude-to-steve channel specifically.
+// Default to that one channel; override with IDEA_CHANNELS="id1,id2" to widen (e.g. add claude-chat).
+const CHANNELS = (process.env.IDEA_CHANNELS || 'C09SW7VQ0RK').split(',').map(s => s.trim()).filter(Boolean);
const STEVE = ENV.STEVE_USER_ID;
const PORT = parseInt(process.env.PORT || '9820', 10);
const OLLAMA = process.env.OLLAMA_URL || 'http://localhost:11434';
-const MODEL = process.env.OLLAMA_MODEL || 'hermes3:8b';
+const MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b'; // hermes3:8b was never installed here; qwen3:14b is the fleet-standard local model
// Basic auth — internal Slack-feed board, gated by default at ideas.agentabrams.com
// like every other internal DW app (unified admin/DW2024!). Override the credential
// with BASIC_AUTH="user:pass"; set BASIC_AUTH="" to make it public again (Steve's call).
@@ -59,31 +61,44 @@ function firstUrl(m) {
function ideaAbout(m) {
const a = (m.attachments || [])[0] || {};
const title = a.title || '';
- const text = (a.text || '').replace(/\s+/g, ' ').slice(0, 400);
+ const text = (a.text || '').replace(/\s+/g, ' ').slice(0, 1200); // show the full unfurl, not a stub
const raw = (m.text || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
return { title, text, note: raw };
}
+// The EXACT post text, verbatim — Slack link markup resolved to readable form and HTML
+// entities decoded, but the words left as Steve typed/pasted them. Powers column 1.
+function exactText(m) {
+ let t = m.text || '';
+ t = t.replace(/<(https?:\/\/[^>|]+)\|([^>]+)>/g, '$2') // <url|label> -> label
+ .replace(/<(https?:\/\/[^>|]+)>/g, '$1') // <url> -> url
+ .replace(/<@([A-Z0-9]+)>/g, '@$1')
+ .replace(/<#[A-Z0-9]+\|([^>]+)>/g, '#$1');
+ t = t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+ return t.trim();
+}
async function ollamaEnrich(idea) {
const prompt = `${BUILD_CONTEXT}
${AA_CONTEXT}
-A new item was posted to Steve's idea feed. Assess it FOR HIS BUILDS, and separately for whether it fits an AGENT ABRAMS build or any existing project.
+Below is the EXACT post Steve dropped in his #claude-to-steve idea channel (usually an X/Twitter
+post, GitHub repo, or tool he wants assessed as build inspiration). Judge it on exactly two things:
+(1) is it a GOOD BUILD — worth building for Steve, yes or no and why; and
+(2) WHAT is it good for — which of Steve's builds / Agent Abrams projects it best powers or slots into.
-URL: ${idea.url}
+The exact post: ${idea.post_text || idea.note || '(none)'}
+URL: ${idea.url || '(none)'}
Link title: ${idea.title || '(none)'}
Link description: ${idea.text || '(none)'}
-Steve's note: ${idea.note || '(none)'}
Respond with ONLY a JSON object, no prose:
{
- "about": "<1-2 sentence plain-English summary of what this idea/link IS>",
- "fit": "<Yes | Maybe | No>",
- "fit_reason": "<1-2 sentences: is it good for Steve's builds and why / why not>",
- "aa_fit": "<Yes | Maybe | No — is it good for an Agent Abrams build or any existing project?>",
- "aa_target": "<name of ONE Agent Abrams build or existing project it best fits, or — if none>",
- "aa_reason": "<1 sentence: which agentabrams.com build or existing project this could power, and how>",
+ "about": "<1-2 sentence plain-English summary of what this post/link IS>",
+ "good_build": "<Yes | Maybe | No>",
+ "good_build_reason": "<1-2 sentences: is it worth BUILDING for Steve, and why / why not>",
+ "good_for": "<1-2 sentences naming the CONCRETE builds or Agent Abrams projects this is good for — name at least one real target (a project, a skill, an agent, or an existing ~/Projects build), or say 'nothing specific' if it truly fits nothing>",
+ "good_for_target": "<the single BEST build/project/skill/agent name it fits, or — if none>",
"next_step": "<exactly one of: Build new project | Add new skill with repo | Create agent | Skip>",
"next_detail": "<1 sentence: concrete suggestion — e.g. a project/skill/agent name and what it does>"
}`;
@@ -93,13 +108,18 @@ Respond with ONLY a JSON object, no prose:
});
const j = await r.json();
const parsed = JSON.parse(j.response);
+ // Local models sometimes return an array/object for a field (e.g. good_for as a list) — coerce
+ // everything to a clean string so the front-end (which esc()s strings) never chokes.
+ const str = v => Array.isArray(v) ? v.filter(Boolean).join('; ') : (v == null ? '' : String(v));
+ const verdict = v => { v = str(v).trim(); return /^y/i.test(v) ? 'Yes' : /^n/i.test(v) ? 'No' : 'Maybe'; };
return {
- about: parsed.about || idea.title || '', fit: parsed.fit || 'Maybe',
- fit_reason: parsed.fit_reason || '',
- aa_fit: parsed.aa_fit || 'Maybe', aa_target: parsed.aa_target || '—',
- aa_reason: parsed.aa_reason || '',
- next_step: parsed.next_step || 'Skip',
- next_detail: parsed.next_detail || '',
+ about: str(parsed.about) || idea.title || '',
+ good_build: verdict(parsed.good_build),
+ good_build_reason: str(parsed.good_build_reason),
+ good_for: str(parsed.good_for),
+ good_for_target: str(parsed.good_for_target) || '—',
+ next_step: str(parsed.next_step) || 'Skip',
+ next_detail: str(parsed.next_detail),
};
}
@@ -115,18 +135,24 @@ async function collectIdeas() {
if (!cursor) break;
}
for (const m of msgs) {
- if (m.subtype || m.bot_id) continue;
- if (STEVE && m.user !== STEVE) continue;
- const url = firstUrl(m); if (!url) continue;
+ // EXACT-POSTS mode (Steve's ask): show every real post in the channel. Drop only Slack
+ // system noise (channel join/leave/topic events); a bot/Claude message or a text-only
+ // post with no link is now KEPT, not filtered out.
+ if (m.subtype && !['bot_message', 'thread_broadcast', 'me_message'].includes(m.subtype)) continue;
+ const url = firstUrl(m);
+ const post_text = exactText(m);
+ if (!post_text && !url) continue; // nothing to render
const about = ideaAbout(m);
- ideas.push({ id: cid + ':' + m.ts, ts: m.ts, channel: CHAN_NAME[cid] || cid, url, ...about,
+ ideas.push({ id: cid + ':' + m.ts, ts: m.ts, channel: CHAN_NAME[cid] || cid, url, post_text,
+ author: m.user === STEVE ? 'Steve' : (m.bot_id ? 'Claude' : (m.user || 'member')), ...about,
when: new Date(parseFloat(m.ts) * 1000).toISOString() });
}
}
ideas.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts));
// DEDUPE by canonical URL (strip tracking params, www, trailing slash) — keep newest.
+ // Text-only posts (no url) can't collide on url, so they key on their own id.
const seen = new Set(), unique = [];
- for (const i of ideas) { const k = canon(i.url); if (seen.has(k)) continue; seen.add(k); unique.push(i); }
+ for (const i of ideas) { const k = i.url ? canon(i.url) : 'txt:' + i.id; if (seen.has(k)) continue; seen.add(k); unique.push(i); }
return unique;
}
function canon(u) {
@@ -156,13 +182,13 @@ let ENRICH_BUSY = false;
const CONCURRENCY = parseInt(process.env.ENRICH_CONCURRENCY || '4', 10); // parallel local-model calls
async function enrichOne(idea) {
try { CACHE[idea.id] = { ...await ollamaEnrich(idea), enriched_at: new Date().toISOString() }; }
- catch (e) { CACHE[idea.id] = { about: idea.title || '', fit: '?', fit_reason: 'AI enrich failed: ' + e.message, aa_fit: '?', aa_target: '—', aa_reason: '', next_step: 'Skip', next_detail: '', error: true }; }
+ catch (e) { CACHE[idea.id] = { about: idea.title || '', good_build: '?', good_build_reason: 'AI enrich failed: ' + e.message, good_for: '', good_for_target: '—', next_step: 'Skip', next_detail: '', error: true }; }
}
async function enrichNew(ideas, cap = 24) {
if (ENRICH_BUSY) return; ENRICH_BUSY = true;
try {
- // Re-enrich records that predate the aa_fit dimension so the backlog backfills too.
- const todo = ideas.filter(i => !CACHE[i.id] || CACHE[i.id].aa_fit === undefined).slice(0, cap);
+ // Re-enrich records that predate the good_build dimension so the backlog backfills too.
+ const todo = ideas.filter(i => !CACHE[i.id] || CACHE[i.id].good_build === undefined).slice(0, cap);
for (let i = 0; i < todo.length; i += CONCURRENCY) {
await Promise.all(todo.slice(i, i + CONCURRENCY).map(enrichOne));
saveCache();
@@ -171,7 +197,7 @@ async function enrichNew(ideas, cap = 24) {
}
// Background drainer: keep enriching the backlog independent of page polls.
async function drain() {
- try { const ideas = await collectIdeas(); if (ideas.some(i => !CACHE[i.id] || CACHE[i.id].aa_fit === undefined)) await enrichNew(ideas, 24); } catch {}
+ try { const ideas = await collectIdeas(); if (ideas.some(i => !CACHE[i.id] || CACHE[i.id].good_build === undefined)) await enrichNew(ideas, 24); } catch {}
setTimeout(drain, 2000);
}
@@ -190,9 +216,28 @@ function buildPrompt(idea, step) {
`Follow the accelerator playbook — pick the matching skills/agents, scaffold + gitify, stand up a local viewer, and stop for my go before anything is deployed or made public.`,
].filter(Boolean).join(' ');
}
-// Launch a real Claude Code session in a new iTerm2 tab. Prompt is passed via a temp file so no
-// shell/AppleScript escaping is needed and nothing from the client is interpolated into a command.
+// Launch a real Claude Code session as a new TAB inside ONE persistent "Idea Builds" iTerm2 window.
+// Every build click adds a tab to the same window instead of spawning a fresh window each time
+// (Steve, 2026-09-04). Prompt is passed via a temp file so no shell/AppleScript escaping is needed
+// and nothing from the client is interpolated into a command.
+//
+// Window identity across independent osascript runs: iTerm2's window `id` is a stable integer for
+// the window's lifetime, but each spawn() is its own process with no shared state — so we persist
+// that id to a marker file. Each launch reads it, looks for a live window with that id, and either
+// creates a tab in it (found) or creates a new window and records its id (missing / first launch).
+const WIN_MARKER = path.join(os.tmpdir(), 'idea-build-window-id');
+// Serialize launches: two rapid build clicks could both read a STALE WIN_MARKER before
+// either wrote its new window id back, so each would spawn its own window. Chain the calls
+// so every launch fully completes (and persists the window id) before the next reads it —
+// the second click then reuses the same "Idea Builds" window as an extra tab.
+let LAUNCH_LOCK = Promise.resolve();
function launchClaude(prompt, cwd) {
+ const run = () => launchClaudeImpl(prompt, cwd);
+ const result = LAUNCH_LOCK.then(run, run);
+ LAUNCH_LOCK = result.then(() => {}, () => {}); // keep the chain alive past a rejection
+ return result;
+}
+function launchClaudeImpl(prompt, cwd) {
const stamp = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const promptFile = path.join(os.tmpdir(), `idea-build-${stamp}.txt`);
const launcher = path.join(os.tmpdir(), `idea-build-${stamp}.sh`);
@@ -200,16 +245,40 @@ function launchClaude(prompt, cwd) {
const dir = fs.existsSync(cwd) ? cwd : os.homedir();
fs.writeFileSync(launcher,
`#!/bin/bash\ncd ${JSON.stringify(dir)} || exit 1\nclaude --model opus "$(cat ${JSON.stringify(promptFile)})"\n`, { mode: 0o755 });
+ // Read the last window id. Inject as a BARE integer (not a quoted string) so the AppleScript
+ // `id of w is savedId` compares int-to-int; anything non-numeric falls back to `missing value`.
+ let savedId = '';
+ try { savedId = fs.readFileSync(WIN_MARKER, 'utf8').trim(); } catch {}
+ const idExpr = /^\d+$/.test(savedId) ? savedId : 'missing value';
const osa = `tell application "iTerm2"
activate
- set newWin to (create window with default profile)
- tell current session of newWin to write text "bash ${launcher}"
+ set savedId to ${idExpr}
+ set targetWin to missing value
+ if savedId is not missing value then
+ repeat with w in windows
+ try
+ if (id of w) is savedId then set targetWin to w
+ end try
+ end repeat
+ end if
+ if targetWin is missing value then
+ set targetWin to (create window with default profile)
+ else
+ tell targetWin to create tab with default profile
+ end if
+ tell current session of current tab of targetWin to write text "bash ${launcher}"
+ return (id of targetWin) as string
end tell`;
return new Promise((resolve) => {
const p = spawn('osascript', ['-e', osa]);
- let err = '';
+ let out = '', err = '';
+ p.stdout.on('data', d => out += d);
p.stderr.on('data', d => err += d);
- p.on('close', code => resolve({ ok: code === 0, code, err: err.trim(), promptFile }));
+ p.on('close', code => {
+ const winId = out.trim();
+ if (code === 0 && /^\d+$/.test(winId)) { try { fs.writeFileSync(WIN_MARKER, winId, 'utf8'); } catch {} }
+ resolve({ ok: code === 0, code, err: err.trim(), promptFile, winId });
+ });
});
}
← 48fce21 creds-safe fetch guard: resolve relative fetch vs credential
·
back to Slack Idea Board
·
idea-board: harden post/open links with scheme allowlist + s 65a7c86 →