[object Object]

← 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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 →