viveworker 0.8.10 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ const WORK_ITEM_SCHEMA_VERSION = 1;
2
+
3
+ const TERMINAL_STATES = new Set(["completed", "failed", "canceled"]);
4
+ const STATE_PRIORITY = new Map([
5
+ ["in_progress", 1],
6
+ ["needs_action", 2],
7
+ ["completed", 3],
8
+ ["failed", 3],
9
+ ["canceled", 3],
10
+ ]);
11
+
12
+ function cleanText(value) {
13
+ return String(value ?? "").trim();
14
+ }
15
+
16
+ function normalizeProvider(value) {
17
+ const provider = cleanText(value).toLowerCase();
18
+ if (["claude", "moltbook", "a2a", "viveworker", "mcp"].includes(provider)) {
19
+ return provider;
20
+ }
21
+ return "codex";
22
+ }
23
+
24
+ function workItemCategory(item) {
25
+ switch (cleanText(item?.kind)) {
26
+ case "approval":
27
+ return cleanText(item?.approvalKind).includes("payment") ? "payment" : "approval";
28
+ case "plan":
29
+ case "plan_ready":
30
+ return "plan";
31
+ case "choice":
32
+ return "question";
33
+ case "moltbook_draft":
34
+ return "draft";
35
+ case "moltbook_reply":
36
+ return "reply";
37
+ case "thread_share":
38
+ return "handoff";
39
+ case "a2a_task":
40
+ return "delegation";
41
+ case "a2a_task_result":
42
+ return "delegation_result";
43
+ case "activity_status":
44
+ return "agent_run";
45
+ case "completion":
46
+ case "assistant_final":
47
+ return "result";
48
+ default:
49
+ return "work";
50
+ }
51
+ }
52
+
53
+ function terminalState(item) {
54
+ const taskStatus = cleanText(item?.taskStatus).toLowerCase();
55
+ if (["failed", "error"].includes(taskStatus)) {
56
+ return "failed";
57
+ }
58
+ if (["canceled", "cancelled", "rejected"].includes(taskStatus)) {
59
+ return "canceled";
60
+ }
61
+ return "completed";
62
+ }
63
+
64
+ function workItemPriority(item, state) {
65
+ if (workItemCategory(item) === "payment") {
66
+ return "urgent";
67
+ }
68
+ if (state === "needs_action") {
69
+ return cleanText(item?.kind) === "thread_share" ? "normal" : "high";
70
+ }
71
+ return state === "in_progress" ? "normal" : "low";
72
+ }
73
+
74
+ function workItemId(item) {
75
+ const provider = normalizeProvider(item?.provider);
76
+ const kind = cleanText(item?.kind) || "item";
77
+ const token = cleanText(item?.token);
78
+ const fallback = [item?.threadId, item?.createdAtMs, item?.title]
79
+ .map(cleanText)
80
+ .filter(Boolean)
81
+ .join(":");
82
+ return `${provider}:${kind}:${token || fallback || "unknown"}`;
83
+ }
84
+
85
+ function workItemThreadKey(item) {
86
+ const threadId = cleanText(item?.threadId);
87
+ return threadId ? `${normalizeProvider(item?.provider)}:${threadId}` : "";
88
+ }
89
+
90
+ function enrichWorkItem(item, state) {
91
+ const normalizedState = TERMINAL_STATES.has(state) ? state : cleanText(state) || "needs_action";
92
+ const requiresAction = normalizedState === "needs_action" && item?.supported !== false;
93
+ return {
94
+ ...item,
95
+ provider: normalizeProvider(item?.provider),
96
+ work: {
97
+ schemaVersion: WORK_ITEM_SCHEMA_VERSION,
98
+ id: workItemId(item),
99
+ state: normalizedState,
100
+ category: workItemCategory(item),
101
+ priority: workItemPriority(item, normalizedState),
102
+ requiresAction,
103
+ sourceRef: {
104
+ provider: normalizeProvider(item?.provider),
105
+ kind: cleanText(item?.kind),
106
+ token: cleanText(item?.token),
107
+ threadId: cleanText(item?.threadId),
108
+ },
109
+ occurredAtMs: Math.max(0, Number(item?.createdAtMs) || 0),
110
+ },
111
+ };
112
+ }
113
+
114
+ function compareWorkItems(left, right) {
115
+ const priorityRank = { urgent: 3, high: 2, normal: 1, low: 0 };
116
+ const priorityDelta = (priorityRank[right?.work?.priority] ?? 0) - (priorityRank[left?.work?.priority] ?? 0);
117
+ if (priorityDelta !== 0) {
118
+ return priorityDelta;
119
+ }
120
+ return Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0);
121
+ }
122
+
123
+ function normalizeBucket(items, state) {
124
+ return (Array.isArray(items) ? items : [])
125
+ .filter((item) => item && typeof item === "object")
126
+ .map((item) => enrichWorkItem(item, state));
127
+ }
128
+
129
+ function dedupeAcrossStates(items) {
130
+ const byId = new Map();
131
+ for (const item of items) {
132
+ const id = cleanText(item?.work?.id);
133
+ if (!id) {
134
+ continue;
135
+ }
136
+ const previous = byId.get(id);
137
+ if (!previous) {
138
+ byId.set(id, item);
139
+ continue;
140
+ }
141
+ const previousRank = STATE_PRIORITY.get(previous.work.state) ?? 0;
142
+ const nextRank = STATE_PRIORITY.get(item.work.state) ?? 0;
143
+ if (nextRank > previousRank || (nextRank === previousRank && Number(item.createdAtMs) > Number(previous.createdAtMs))) {
144
+ byId.set(id, item);
145
+ }
146
+ }
147
+ return [...byId.values()];
148
+ }
149
+
150
+ export function buildWorkItemResponse({ pending = [], inProgress = [], completed = [], generatedAtMs = Date.now() } = {}) {
151
+ const pendingItems = normalizeBucket(pending, "needs_action");
152
+ const blockedThreadKeys = new Set(pendingItems.map(workItemThreadKey).filter(Boolean));
153
+ const activeItems = normalizeBucket(inProgress, "in_progress")
154
+ .filter((item) => !blockedThreadKeys.has(workItemThreadKey(item)));
155
+ const completedItems = (Array.isArray(completed) ? completed : [])
156
+ .filter((item) => item && typeof item === "object")
157
+ .map((item) => enrichWorkItem(item, terminalState(item)));
158
+
159
+ const deduped = dedupeAcrossStates([...activeItems, ...pendingItems, ...completedItems]);
160
+ const needsAction = deduped.filter((item) => item.work.state === "needs_action").sort(compareWorkItems);
161
+ const active = deduped.filter((item) => item.work.state === "in_progress").sort(compareWorkItems);
162
+ const terminal = deduped.filter((item) => TERMINAL_STATES.has(item.work.state)).sort(compareWorkItems);
163
+
164
+ return {
165
+ schemaVersion: WORK_ITEM_SCHEMA_VERSION,
166
+ generatedAtMs: Math.max(0, Number(generatedAtMs) || Date.now()),
167
+ counts: {
168
+ needsAction: needsAction.length,
169
+ inProgress: active.length,
170
+ completed: terminal.filter((item) => item.work.state === "completed").length,
171
+ failed: terminal.filter((item) => item.work.state === "failed").length,
172
+ },
173
+ pending: needsAction,
174
+ inProgress: active,
175
+ completed: terminal,
176
+ };
177
+ }
178
+
179
+ export { WORK_ITEM_SCHEMA_VERSION };
@@ -62,6 +62,33 @@ export const DEFAULT_INBOX_DIR = path.join(os.homedir(), ".viveworker", "moltboo
62
62
  export const DEFAULT_SCOUT_STATE_FILE = path.join(os.homedir(), ".viveworker", "moltbook-scout-state.json");
63
63
  export const DEFAULT_DRAFTS_DIR = path.join(os.homedir(), ".viveworker", "moltbook-drafts");
64
64
 
65
+ const PROVIDER_ERROR_TEXT_PATTERNS = [
66
+ /\bfailed to authenticate\b/iu,
67
+ /\binvalid authentication credentials\b/iu,
68
+ /\bauthentication_error\b/iu,
69
+ /\bapi error:\s*(?:401|403)\b/iu,
70
+ /\bincorrect api key\b/iu,
71
+ /\binvalid api key\b/iu,
72
+ ];
73
+
74
+ export function looksLikeProviderErrorText(value) {
75
+ const text = String(value || "").trim();
76
+ if (!text) return false;
77
+ const compact = text.replace(/\s+/gu, " ");
78
+ if (PROVIDER_ERROR_TEXT_PATTERNS.some((pattern) => pattern.test(compact))) {
79
+ return true;
80
+ }
81
+ if (!compact.startsWith("{")) return false;
82
+ try {
83
+ const parsed = JSON.parse(compact);
84
+ const type = String(parsed?.error?.type || parsed?.type || "").toLowerCase();
85
+ const message = String(parsed?.error?.message || parsed?.message || "").toLowerCase();
86
+ return type === "authentication_error" || message.includes("invalid authentication credentials");
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
65
92
  export async function loadMoltbookEnv(envFile = DEFAULT_ENV_FILE) {
66
93
  const env = {};
67
94
  try {
@@ -36,6 +36,7 @@ import {
36
36
  pickInboxReplyCandidate,
37
37
  reconcileInboxDraftMarkers,
38
38
  getMoltbookReplyQuotaState,
39
+ looksLikeProviderErrorText,
39
40
  loopbackFetch,
40
41
  } from "./moltbook-api.mjs";
41
42
 
@@ -572,6 +573,9 @@ async function cmdPropose(postId, flags) {
572
573
  if (!postId) fail("usage: viveworker moltbook propose <postId> --text \"...\"");
573
574
  const text = typeof flags.text === "string" ? flags.text : "";
574
575
  if (!text.trim()) fail("--text is required and must be non-empty");
576
+ if (looksLikeProviderErrorText(text)) {
577
+ fail("--text looks like an LLM/provider authentication error; refusing to create a Moltbook draft");
578
+ }
575
579
  const ttlSec = Number(flags.ttl) || Number(flags.timeout) || 86400;
576
580
  const parentCommentId = typeof flags["parent-id"] === "string" ? flags["parent-id"] : "";
577
581
  const postTitle = typeof flags.title === "string" ? flags.title : "";
@@ -948,10 +952,6 @@ async function cmdCompose(flags) {
948
952
  return;
949
953
  }
950
954
 
951
- // Mark slot as attempted (even before drafting, to avoid re-proposal on deny).
952
- state.composeSlotsAttempted = [...attempted, slot];
953
- await writeScoutState(state);
954
-
955
955
  // Load persona.
956
956
  let persona = "";
957
957
  try {
@@ -1020,6 +1020,14 @@ async function cmdComposePropose(flags) {
1020
1020
  }
1021
1021
  const token = submitRes?.token;
1022
1022
  if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
1023
+ if (slot) {
1024
+ const state = rollScoutDayIfNeeded(await readScoutState());
1025
+ const attempted = Array.isArray(state.composeSlotsAttempted) ? state.composeSlotsAttempted : [];
1026
+ if (!attempted.includes(slot)) {
1027
+ state.composeSlotsAttempted = [...attempted, slot];
1028
+ await writeScoutState(state);
1029
+ }
1030
+ }
1023
1031
 
1024
1032
  console.log(JSON.stringify({ ok: true, token, ttlSec, fireAndForget: true }));
1025
1033
  }
@@ -39,19 +39,88 @@ WINDOW_MS=$((WINDOW_SEC * 1000))
39
39
  MAX_SKIP=5
40
40
 
41
41
  # ── Detect harness ────────────────────────────────────────────
42
+ resolve_harness_bin() {
43
+ local harness="$1"
44
+ local configured="${2:-}"
45
+ local resolved=""
46
+
47
+ if [ -n "$configured" ]; then
48
+ if [ -x "$configured" ]; then
49
+ printf '%s\n' "$configured"
50
+ return 0
51
+ fi
52
+ resolved=$(command -v "$configured" 2>/dev/null || true)
53
+ if [ -n "$resolved" ] && [ -x "$resolved" ]; then
54
+ printf '%s\n' "$resolved"
55
+ return 0
56
+ fi
57
+ fi
58
+
59
+ resolved=$(command -v "$harness" 2>/dev/null || true)
60
+ if [ -n "$resolved" ] && [ -x "$resolved" ]; then
61
+ printf '%s\n' "$resolved"
62
+ return 0
63
+ fi
64
+
65
+ # Desktop app updates can move the bundled Codex binary without rewriting
66
+ # an already-installed LaunchAgent. Resolve the current app bundle at every
67
+ # run so a stale absolute path does not disable scoring and drafting.
68
+ if [ "$harness" = "codex" ]; then
69
+ local candidate=""
70
+ for candidate in \
71
+ "/Applications/ChatGPT.app/Contents/Resources/codex" \
72
+ "/Applications/Codex.app/Contents/Resources/codex" \
73
+ "$HOME/Applications/ChatGPT.app/Contents/Resources/codex" \
74
+ "$HOME/Applications/Codex.app/Contents/Resources/codex"; do
75
+ if [ -x "$candidate" ]; then
76
+ printf '%s\n' "$candidate"
77
+ return 0
78
+ fi
79
+ done
80
+ fi
81
+
82
+ return 1
83
+ }
84
+
42
85
  HARNESS="${SCOUT_HARNESS:-}"
86
+ CONFIGURED_HARNESS_BIN="${SCOUT_HARNESS_BIN:-}"
43
87
  HARNESS_BIN=""
44
- if [ -z "$HARNESS" ]; then
45
- if command -v claude >/dev/null 2>&1; then
46
- HARNESS="claude"
47
- elif command -v codex >/dev/null 2>&1; then
88
+ if [ -n "$HARNESS" ]; then
89
+ HARNESS_BIN=$(resolve_harness_bin "$HARNESS" "$CONFIGURED_HARNESS_BIN" || true)
90
+ else
91
+ HARNESS_BIN=$(resolve_harness_bin "codex" "" || true)
92
+ if [ -n "$HARNESS_BIN" ]; then
48
93
  HARNESS="codex"
94
+ else
95
+ HARNESS_BIN=$(resolve_harness_bin "claude" "" || true)
96
+ if [ -n "$HARNESS_BIN" ]; then
97
+ HARNESS="claude"
98
+ fi
49
99
  fi
50
100
  fi
51
- if [ -n "$HARNESS" ]; then
52
- HARNESS_BIN=$(command -v "$HARNESS" 2>/dev/null || true)
101
+ if [ -z "${SCOUT_DRAFT_TIMEOUT:-}" ] && [ "$HARNESS" = "codex" ]; then
102
+ DRAFT_TIMEOUT_SEC=300
53
103
  fi
54
104
 
105
+ run_harness_prompt() {
106
+ local timeout_sec="$1"
107
+ local prompt="$2"
108
+
109
+ if [ "$HARNESS" = "claude" ]; then
110
+ portable_timeout "$timeout_sec" "$HARNESS_BIN" -p "$prompt" --output-format text
111
+ elif [ "$HARNESS" = "codex" ]; then
112
+ printf '%s' "$prompt" | portable_timeout "$timeout_sec" "$HARNESS_BIN" exec \
113
+ -C "$SCRIPT_DIR/.." \
114
+ --sandbox read-only \
115
+ --ephemeral \
116
+ --ignore-user-config \
117
+ --ignore-rules \
118
+ --color never
119
+ else
120
+ return 127
121
+ fi
122
+ }
123
+
55
124
  # ── Load persona & avoid keywords ─────────────────────────────
56
125
  PERSONA=""
57
126
  AVOID_PATTERN=""
@@ -86,6 +155,19 @@ candidate_should_skip() {
86
155
  printf '%s\n%s' "$1" "$2" | grep -iqE "$AVOID_PATTERN"
87
156
  }
88
157
 
158
+ looks_like_harness_error() {
159
+ printf '%s' "$1" | grep -Eiq 'failed to authenticate|invalid authentication credentials|authentication_error|api error:[[:space:]]*(401|403)|incorrect api key|invalid api key'
160
+ }
161
+
162
+ abort_if_harness_error() {
163
+ local stage="$1"
164
+ local text="$2"
165
+ if looks_like_harness_error "$text"; then
166
+ echo "[scout-auto] $stage: harness returned a provider authentication error; not proposing a Moltbook draft"
167
+ exit 1
168
+ fi
169
+ }
170
+
89
171
  # Temp file for JSON interchange
90
172
  SCOUT_TMP=$(mktemp /tmp/viveworker-scout-XXXXXX.json)
91
173
  LOCK_DIR="${TMPDIR:-/tmp}/viveworker-moltbook-scout.lock"
@@ -160,16 +242,12 @@ INTENT: (1-2 sentences in Japanese: why this reply angle fits the incoming comme
160
242
  INBOX_PROMPT_EOF
161
243
  )"
162
244
 
163
- INBOX_DRAFT_TEXT=""
164
- if [ "$HARNESS" = "claude" ]; then
165
- INBOX_DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" -p "$INBOX_PROMPT" --output-format text 2>/dev/null) || true
166
- elif [ "$HARNESS" = "codex" ]; then
167
- INBOX_DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" exec "$INBOX_PROMPT" 2>/dev/null) || true
168
- fi
245
+ INBOX_DRAFT_TEXT=$(run_harness_prompt "$DRAFT_TIMEOUT_SEC" "$INBOX_PROMPT" 2>/dev/null) || true
169
246
 
170
247
  if [ -z "$INBOX_DRAFT_TEXT" ]; then
171
248
  echo "[scout-auto] inbox: harness returned empty draft or timed out after ${DRAFT_TIMEOUT_SEC}s"
172
249
  else
250
+ abort_if_harness_error "inbox" "$INBOX_DRAFT_TEXT"
173
251
  INBOX_INTENT=""
174
252
  INBOX_REPLY_BODY=""
175
253
  if echo "$INBOX_DRAFT_TEXT" | grep -q "^---$"; then
@@ -210,16 +288,17 @@ fi
210
288
  # Max 3 approved posts/day. Each slot proposed at most once.
211
289
  # ═══════════════════════════════════════════════════════════════
212
290
  COMPOSE_MAX="${COMPOSE_MAX:-3}"
291
+ COMPOSE_ACTIVITY_LIMIT="${SCOUT_COMPOSE_ACTIVITY_LIMIT:-30}"
213
292
  COMPOSE_JSON=$("$NODE" "$VIVEWORKER" moltbook compose --max-daily "$COMPOSE_MAX" 2>/dev/null) || true
214
293
  COMPOSE_STATUS=$(json_field "$COMPOSE_JSON" "status")
215
294
 
216
295
  if [ "$COMPOSE_STATUS" = "material" ] && [ -n "$HARNESS_BIN" ]; then
217
296
  COMPOSE_SLOT=$(json_field "$COMPOSE_JSON" "slot")
218
297
  ACTIVITY_COUNT=$(json_field "$COMPOSE_JSON" "activityCount")
219
- ACTIVITY_SUMMARY=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);const e=o.activitySummary||[];console.log(e.map(x=>'- ['+x.kind+'] '+x.title+(x.summary?' — '+x.summary:'')).join('\n'))}catch{}})")
220
- RECENT_TITLES=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log((o.recentTitles||[]).join(', '))}catch{}})")
298
+ ACTIVITY_SUMMARY=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);const limit=Number('$COMPOSE_ACTIVITY_LIMIT')||30;const e=(o.activitySummary||[]).slice(0,limit);console.log(e.map(x=>'- ['+x.kind+'] '+x.title+(x.summary?' — '+x.summary:'')).join('\n'))}catch{}})")
299
+ RECENT_TITLES=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);const titles=(o.recentTitles||[]).map(x=>typeof x==='string'?x:(x&&x.title)||'').filter(Boolean).slice(0,10);console.log(titles.join(', '))}catch{}})")
221
300
 
222
- echo "[scout-auto] compose ($COMPOSE_SLOT): ${ACTIVITY_COUNT} activities found"
301
+ echo "[scout-auto] compose ($COMPOSE_SLOT): ${ACTIVITY_COUNT} activities found (using ${COMPOSE_ACTIVITY_LIMIT})"
223
302
 
224
303
  # Slot-specific tone instructions.
225
304
  case "$COMPOSE_SLOT" in
@@ -275,14 +354,10 @@ INTENT: (1-2 sentences in Japanese: why this is worth posting from this persona
275
354
  COMPOSE_EOF
276
355
  )"
277
356
 
278
- COMPOSE_DRAFT=""
279
- if [ "$HARNESS" = "claude" ]; then
280
- COMPOSE_DRAFT=$(portable_timeout 60 "$HARNESS_BIN" -p "$COMPOSE_PROMPT" --output-format text 2>/dev/null) || true
281
- elif [ "$HARNESS" = "codex" ]; then
282
- COMPOSE_DRAFT=$(portable_timeout 60 "$HARNESS_BIN" exec "$COMPOSE_PROMPT" 2>/dev/null) || true
283
- fi
357
+ COMPOSE_DRAFT=$(run_harness_prompt "$DRAFT_TIMEOUT_SEC" "$COMPOSE_PROMPT" 2>/dev/null) || true
284
358
 
285
359
  if [ -n "$COMPOSE_DRAFT" ]; then
360
+ abort_if_harness_error "compose ($COMPOSE_SLOT)" "$COMPOSE_DRAFT"
286
361
  # Check for NO_MATCH signal (persona doesn't align with today's work).
287
362
  if echo "$COMPOSE_DRAFT" | grep -q "NO_MATCH"; then
288
363
  echo "[scout-auto] compose ($COMPOSE_SLOT): persona has no match with current activities — skipping"
@@ -393,17 +468,13 @@ PROMPT_EOF
393
468
  )"
394
469
 
395
470
  echo "[scout-auto] drafting via $HARNESS for '$BEST_TITLE'"
396
- DRAFT_TEXT=""
397
- if [ "$HARNESS" = "claude" ]; then
398
- DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" -p "$DRAFT_PROMPT" --output-format text 2>/dev/null) || true
399
- elif [ "$HARNESS" = "codex" ]; then
400
- DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" exec "$DRAFT_PROMPT" 2>/dev/null) || true
401
- fi
471
+ DRAFT_TEXT=$(run_harness_prompt "$DRAFT_TIMEOUT_SEC" "$DRAFT_PROMPT" 2>/dev/null) || true
402
472
 
403
473
  if [ -z "$DRAFT_TEXT" ]; then
404
474
  echo "[scout-auto] error: harness returned empty draft or timed out after ${DRAFT_TIMEOUT_SEC}s"
405
475
  exit 1
406
476
  fi
477
+ abort_if_harness_error "draft" "$DRAFT_TEXT"
407
478
 
408
479
  # Parse INTENT and reply body
409
480
  INTENT=""
@@ -498,11 +569,13 @@ Post body (first 500 chars): ${POST_CONTENT:0:500}
498
569
 
499
570
  Reply with ONLY a number from 0 to 100. Nothing else."
500
571
 
501
- SCORE_RAW=""
502
- if [ "$HARNESS" = "claude" ]; then
503
- SCORE_RAW=$(portable_timeout 30 "$HARNESS_BIN" -p "$SCORE_PROMPT" --output-format text 2>/dev/null) || true
504
- elif [ "$HARNESS" = "codex" ]; then
505
- SCORE_RAW=$(portable_timeout 30 "$HARNESS_BIN" exec "$SCORE_PROMPT" 2>/dev/null) || true
572
+ SCORE_RAW=$(run_harness_prompt 30 "$SCORE_PROMPT" 2>/dev/null) || true
573
+ abort_if_harness_error "score" "$SCORE_RAW"
574
+
575
+ if [ -z "$SCORE_RAW" ]; then
576
+ SKIP_COUNT=$((SKIP_COUNT + 1))
577
+ echo "[scout-auto] skipping (score failed): $POST_TITLE (by @$POST_AUTHOR) [$SKIP_COUNT/$MAX_SKIP]"
578
+ continue
506
579
  fi
507
580
 
508
581
  SCORE=$(echo "$SCORE_RAW" | tr -dc '0-9\n' | head -1)
@@ -515,11 +588,6 @@ Reply with ONLY a number from 0 to 100. Nothing else."
515
588
  continue
516
589
  fi
517
590
 
518
- if [ -z "$SCORE_RAW" ]; then
519
- SKIP_COUNT=$((SKIP_COUNT + 1))
520
- echo "[scout-auto] skipping (score failed): $POST_TITLE (by @$POST_AUTHOR) [$SKIP_COUNT/$MAX_SKIP]"
521
- continue
522
- fi
523
591
  else
524
592
  SCORE=50
525
593
  fi