[object Object]

← back to Slack To Steve

slack-to-steve: armed claude -p path (rails-gated, per-channel scope) + watch loop + arm.sh (Steve-run)

0116c5c06c7456ee45dc51393a2844c3493b5c3f · 2026-07-13 01:15:19 -0700 · Steve Abrams

Files touched

Diff

commit 0116c5c06c7456ee45dc51393a2844c3493b5c3f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 01:15:19 2026 -0700

    slack-to-steve: armed claude -p path (rails-gated, per-channel scope) + watch loop + arm.sh (Steve-run)
---
 arm.sh     | 29 +++++++++++++++++++++
 ingest.mjs | 85 +++++++++++++++++++++++++++++++++++---------------------------
 2 files changed, 77 insertions(+), 37 deletions(-)

diff --git a/arm.sh b/arm.sh
new file mode 100755
index 0000000..7e8dcda
--- /dev/null
+++ b/arm.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# arm.sh — YOU run this (it enables autonomous claude -p from Slack). Steve-authorized only.
+# Default: arms ONLY #claude-chat (C0BGQ2QLR35). To also arm #claude-to-steve, add its id
+# to ARMED_CHANNELS below (or set ARMED_CHANNELS= empty to arm every watched channel).
+set -e
+cd ~/Projects/slack-to-steve
+
+# 1. flip the switch + scope arming to #claude-chat only
+sed -i '' 's/^AUTO_EXECUTE=.*/AUTO_EXECUTE=1/' .env
+grep -q '^ARMED_CHANNELS=' .env && sed -i '' 's/^ARMED_CHANNELS=.*/ARMED_CHANNELS=C0BGQ2QLR35/' .env || echo 'ARMED_CHANNELS=C0BGQ2QLR35' >> .env
+
+# 2. seed cursors to NOW so your 174 old link-drops are NOT executed as commands
+node -e '
+const fs=require("fs");
+const env=fs.readFileSync(".env","utf8");
+const tok=(env.match(/SLACK_BOT_TOKEN=(.*)/)||[])[1].trim();
+const chans=((env.match(/SLACK_CHANNEL_IDS=(.*)/)||[])[1]||"").trim().split(",").map(s=>s.trim()).filter(Boolean);
+fs.mkdirSync("data",{recursive:true});
+(async()=>{for(const c of chans){const r=await fetch("https://slack.com/api/conversations.history?channel="+c+"&limit=1",{headers:{Authorization:"Bearer "+tok}});const j=await r.json();const ts=(j.messages&&j.messages[0]&&j.messages[0].ts)||"0";fs.writeFileSync("data/last-ts-"+c+".txt",ts);console.log("  cursor",c,"->",ts);}})();
+'
+
+# 3. run the watcher under pm2 (90s loop, survives restarts)
+pm2 delete slack-ingest >/dev/null 2>&1 || true
+WATCH=1 pm2 start ingest.mjs --name slack-ingest
+pm2 save
+echo ""
+echo "✅ ARMED — #claude-chat now auto-executes your messages via claude -p (rails enforce gates)."
+echo "   Test it: post a SAFE task in #claude-chat, e.g. \"what pm2 processes are running?\""
+echo "   Disarm anytime:  sed -i '' 's/^AUTO_EXECUTE=.*/AUTO_EXECUTE=0/' ~/Projects/slack-to-steve/.env && pm2 restart slack-ingest"
diff --git a/ingest.mjs b/ingest.mjs
index c74123f..3daf8ee 100644
--- a/ingest.mjs
+++ b/ingest.mjs
@@ -1,17 +1,13 @@
-// ingest.mjs — the two-way poller (george-reply-ingest for Slack).
+// ingest.mjs — Slack two-way poller (george-reply-ingest pattern, for Slack).
 //
-// Watches one OR MORE channels (SLACK_CHANNEL_IDS, comma-separated). For each,
-// reads NEW messages Steve posts, verifies the sender, posts a threaded
-// acknowledgement, and QUEUES the instruction to inbox.jsonl.
+// Watches SLACK_CHANNEL_IDS (comma-sep). For each new message from Steve: verify
+// sender → (armed) run it via `claude -p` with RAILS → reply in-thread; or (safe)
+// capture + ack + queue. Runs once, OR loops with --watch / WATCH=1.
 //
-// SAFETY RAILS (match george-reply-ingest):
-//   • Does NOT autonomously execute by default. Auto-execute via `claude -p` is the
-//     ARMED step — only when AUTO_EXECUTE=1 AND Steve has approved it. Until then it
-//     captures + acks + queues; a human actions the queue.
-//   • Sender allowlist: only STEVE_USER_ID messages are processed.
-//   • Per-channel cursor (data/last-ts-<cid>.txt) so a message is never processed twice.
-//
-// Scopes: channels:history (public) + groups:history (private) + chat:write. $0.
+// ARMED (AUTO_EXECUTE=1): spawns `claude -p "<rails+message>" --dangerously-skip-permissions`.
+// The gate is enforced by the RAILS PREAMBLE (not the permission system): the spawned
+// Claude does safe/reversible work and STOPS on gated actions, replying what it would do.
+// Mirrors the blessed george-reply-ingest invocation.
 import fs from 'fs';
 import path from 'path';
 import { execFile } from 'child_process';
@@ -23,28 +19,36 @@ const CHANNELS = (ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || process.env.S
   .split(',').map(s => s.trim()).filter(Boolean);
 const STEVE = ENV.STEVE_USER_ID || process.env.STEVE_USER_ID;
 const AUTO_EXECUTE = (ENV.AUTO_EXECUTE || process.env.AUTO_EXECUTE || '0') === '1';
-const CLAUDE_CMD = ENV.CLAUDE_CMD || process.env.CLAUDE_CMD || 'claude';
+// Optional: restrict auto-execute to a subset of channels (comma-sep IDs). Empty = all watched channels.
+const ARMED_CHANNELS = (ENV.ARMED_CHANNELS || process.env.ARMED_CHANNELS || '').split(',').map(s => s.trim()).filter(Boolean);
+const armedFor = cid => AUTO_EXECUTE && (ARMED_CHANNELS.length === 0 || ARMED_CHANNELS.includes(cid));
+const CLAUDE_CMD = ENV.CLAUDE_CMD || process.env.CLAUDE_CMD || '/Users/macstudio3/.local/bin/claude';
+const WATCH = process.argv.includes('--watch') || process.env.WATCH === '1';
+const INTERVAL = parseInt(ENV.WATCH_INTERVAL_MS || process.env.WATCH_INTERVAL_MS || '90000', 10);
 const INBOX = path.join(DIR, 'data', 'inbox.jsonl');
 
-function loadEnv(p) {
-  const o = {};
-  try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {}
-  return o;
-}
+const RAILS = (msg) => `You are acting on a message Steve just sent you in his Slack channel — this is him talking to Claude to get work done. Do what he asks NOW, using all your tools/skills.
+
+RAILS (the real protection — honor them even though this runs with --dangerously-skip-permissions):
+- SAFE / reversible / local / read-only work → just do it and report the result.
+- GATED action — anything customer-facing, a prod or dw_unified or Shopify write, a send-to-list, DNS/domain, money/spend, secrets, a deploy, a remote push, a bulk delete/archive, or installing a scheduled job → DO NOT perform it. Instead say exactly what you would do, why it's gated, and that it needs his explicit in-session go.
+Keep your FINAL output short and concrete — it gets posted straight back to him in Slack.
+
+Steve's message:
+${msg}`;
+
+function loadEnv(p) { const o = {}; try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {} return o; }
 async function slack(method, params) {
-  const r = await fetch('https://slack.com/api/' + method, {
-    method: 'POST',
-    headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/x-www-form-urlencoded' },
-    body: new URLSearchParams(params),
-  });
+  const r = await fetch('https://slack.com/api/' + method, { method: 'POST', headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params) });
   return r.json();
 }
 const log = m => console.log(new Date().toISOString() + ' ' + m);
-const clip = s => { s = String(s || '').trim(); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(no output)'); };
+const clip = s => { s = String(s || '').trim(); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(done — no output)'); };
 function runClaude(prompt) {
   return new Promise((resolve, reject) =>
-    execFile(CLAUDE_CMD, ['-p', prompt], { timeout: 240000, maxBuffer: 10 * 1024 * 1024 }, (err, out, se) =>
-      err ? reject(new Error((se || err.message).slice(0, 400))) : resolve(out)));
+    execFile(CLAUDE_CMD, ['-p', RAILS(prompt), '--dangerously-skip-permissions'],
+      { timeout: 600000, maxBuffer: 20 * 1024 * 1024, cwd: DIR },
+      (err, out, se) => err ? reject(new Error((se || err.message).slice(0, 500))) : resolve(out)));
 }
 
 async function processChannel(cid) {
@@ -52,33 +56,40 @@ async function processChannel(cid) {
   const oldest = fs.existsSync(cursorFile) ? fs.readFileSync(cursorFile, 'utf8').trim() : '0';
   const hist = await slack('conversations.history', { channel: cid, oldest: oldest || '0', limit: '50', inclusive: 'false' });
   if (!hist.ok) { log(`[${cid}] history error: ${hist.error}`); return 0; }
-
   const msgs = (hist.messages || []).filter(m => m.type === 'message' && !m.subtype).reverse();
   let newest = oldest, acted = 0;
   for (const m of msgs) {
     if (parseFloat(m.ts) <= parseFloat(oldest || '0')) continue;
     newest = m.ts;
     if (m.user !== STEVE) continue;
-    const text = (m.text || '').trim();
-    if (!text) continue;
-    fs.appendFileSync(INBOX, JSON.stringify({ channel: cid, ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), executed: false }) + '\n');
+    const text = (m.text || '').trim(); if (!text) continue;
+    const armed = armedFor(cid);
+    fs.appendFileSync(INBOX, JSON.stringify({ channel: cid, ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), armed }) + '\n');
     acted++;
-    if (AUTO_EXECUTE) {
-      await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⏳ On it — working on this now.' });
-      runClaude(text).then(o => slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: clip(o) }))
-                     .catch(e => slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⚠️ ' + e.message }));
+    if (armed) {
+      await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⏳ On it — working on this now…' });
+      try { const out = await runClaude(text); await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: clip(out) }); }
+      catch (e) { await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⚠️ ' + e.message }); }
     } else {
       await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '📥 Got it — queued for Claude. (auto-execute is off; a session will action this.)' });
     }
+    // advance cursor after EACH handled message so a mid-run restart never re-runs it
+    fs.writeFileSync(cursorFile, newest);
   }
   if (parseFloat(newest) > parseFloat(oldest || '0')) fs.writeFileSync(cursorFile, newest);
   return acted;
 }
 
-(async () => {
-  if (!TOKEN || !CHANNELS.length || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_CHANNEL_IDS / STEVE_USER_ID) — see .env.example'); process.exit(1); }
+async function tick() {
   fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
   let total = 0;
   for (const cid of CHANNELS) total += await processChannel(cid);
-  log(`processed ${total} new message(s) from Steve across ${CHANNELS.length} channel(s): ${CHANNELS.join(', ')}`);
+  log(`processed ${total} new message(s) from Steve · armed=${AUTO_EXECUTE} · channels=${CHANNELS.length}`);
+}
+
+(async () => {
+  if (!TOKEN || !CHANNELS.length || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_CHANNEL_IDS / STEVE_USER_ID)'); process.exit(1); }
+  log(`slack ingest starting · armed=${AUTO_EXECUTE} · watch=${WATCH}`);
+  await tick();
+  if (WATCH) setInterval(() => tick().catch(e => log('tick error: ' + e.message)), INTERVAL);
 })();

← 27adc1d auto-save: 2026-07-13T00:51:43 (1 files) — exports/  ·  back to Slack To Steve  ·  arm.sh: arm BOTH channels (Steve's call) bef02c4 →