Skip to content

test(security): regression guard for task: field-last invariant - #44

Merged
amkunte merged 32 commits into
local-mainfrom
test/task-field-order-regression
May 22, 2026
Merged

amkunte merged 32 commits into
local-mainfrom
test/task-field-order-regression

Conversation

@amkunte

@amkunte amkunte commented May 22, 2026

Copy link
Copy Markdown
Owner

Summary

Adds source-grep regression tests for all 8 task writers fixed in PRs #38–#43. Each test locates the writer's f-string composition block and asserts task: appears after security-relevant headers (source, access_tier, priority). A future writer that puts task: earlier fails here immediately.

⚠️ Merge order dependency

This test will be red on local-main until the fix PRs are merged. Correct merge order:

  1. fix(task-format): stop _isVoiceTask + parse_priority at task: delimiter #38 (voice/context-drop + _isVoiceTask/parse_priority stop-at-task)
  2. fix(task-bridge): move task: field last in writeChatTask writer #40 (writeChatTask)
  3. fix(bridges): move task: field last in discord + telegram task writers #41 (telegram-bridge, discord-bridge)
  4. fix(agent-api,slack-bridge): move task: field last in all task writers #42 (agent-api ×3, slack-bridge)
  5. fix(github-webhook): move task: field last in task writer #43 (github-webhook)
  6. This PR (tests go green)

This is intentional — the tests document the expected post-fix state, not the current local-main state.

Writers covered

Writer Test
telegram-bridge.py test_telegram_bridge_task_field_last
discord-bridge.py test_discord_bridge_task_field_last
slack-bridge.py test_slack_bridge_task_field_last
github-webhook.py test_github_webhook_task_field_last
agent-api.py /twilio/voice test_agent_api_twilio_voice_task_field_last
agent-api.py /twilio/sms test_agent_api_twilio_sms_task_field_last
agent-api.py /twilio/transcription test_agent_api_twilio_voicemail_task_field_last
task-bridge.ts writeChatTask test_task_bridge_write_chat_task_field_last
conversation-server.ts already correct — excluded

🤖 Generated with Claude Code

amkunte and others added 30 commits May 22, 2026 09:03
Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.
Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).
The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.
Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".
Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).
…sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ce (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…older (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lity (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.
…ts[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.
amkunte and others added 2 commits May 22, 2026 09:08
…epo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@amkunte
amkunte merged commit 4d5fc19 into local-main May 22, 2026
@amkunte
amkunte deleted the test/task-field-order-regression branch May 22, 2026 19:29
amkunte added a commit that referenced this pull request May 23, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 23, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 24, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 24, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 29, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 29, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 30, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request May 30, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 1, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 1, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 1, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 4, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 4, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 6, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 6, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 10, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 10, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 11, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 11, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 21, 2026
* feat(phone-server): add /twilio/sms webhook for inbound SMS

Adds an SMS path to conversation-server.ts that mirrors the existing
agent-api SMS handler. Lets us point Twilio's "A message comes in"
webhook at the same ngrok tunnel (port 3100) already in use for voice,
removing the need for a second ngrok tunnel — the free tier is one
tunnel only.

Behavior matches /twilio/sms in src/agent-api.py:
- Parses From and Body from the urlencoded POST
- Writes a tasks/task-{ts}.txt with source=twilio_sms
- Replies with TwiML <Message>Got it. Sutando is on it.</Message>

Tested locally: POST returns valid TwiML and produces a task file.

* feat(sms-bridge): outbound SMS reply bridge for inbound twilio_sms tasks

Polls tasks/ for files where source=twilio_sms (written by the
phone-server's /twilio/sms handler), indexes task_id → from_number,
and when results/{task_id}.txt appears, sends the body back to the
sender via Twilio Messages API and deletes the result file.

Mirrors src/telegram-bridge.py's pending-replies pattern; runs as a
small daemon launched by src/startup.sh under section 6b alongside
the other channel bridges. Skipped with SKIP_SMS_BRIDGE=1 or if any
of TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_PHONE_NUMBER are
missing.

Cleanup: HTTP failures retry until the result file is 5 min old, then
the bridge gives up to avoid wedging on permanent 4xx (e.g. revoked
phone number). Empty results are dropped immediately.

Tested: live inbound SMS from owner number → task file written by
phone-server → loop produced result → bridge delivered SMS reply
(SID SMe0308b9...).

* fix(sms-bridge): fall back to Telegram on Twilio undelivered/failed

The previous version treated HTTP 201 from Twilio Messages as
success, but 201 just means "enqueued" — carriers can still drop
the message later (e.g. A2P 10DLC error 30034 on US long-codes).
Owner reported every SMS reply since 22:37 UTC silently dropped:
status=undelivered, error_code=30034.

After enqueue, the bridge now tracks each SID in an in-flight
map and polls /Messages/{sid}.json after 4s. On status in
{undelivered, failed} (or stuck in queued/sending after 60s), it
drops a proactive- file so the existing telegram-bridge delivers
the reply via Telegram instead, prefixed with the failure reason.

Backstop: HTTP-level enqueue failures (auth/4xx) also fall back
to Telegram once the result is >60s old, instead of looping the
retry forever.

Net effect: SMS round-trips become channel-agnostic — owner gets
the reply on whichever channel works, without losing the body.
Once Twilio A2P 10DLC registration lands, all replies will route
back to SMS and the fallback path stays dormant.

* feat(health-check): cover sms-bridge

Adds the new SMS reply bridge to the health-check service iteration
alongside telegram-bridge and discord-bridge. Configuration check
differs: sms-bridge reads TWILIO_* from workspace .env, not
~/.claude/channels/sms/.{env,access.json}. The check goes ok
when the daemon is running, warn when configured-but-not-running,
and is silently skipped when the env vars are missing or
SKIP_SMS_BRIDGE=1.

Without this, a crashed sms-bridge would surface only when the
owner notices replies dropping — health-check is the canonical
"is anything broken" surface and needs to know about every bridge
in 6b of startup.sh.

Tested: pkill the bridge → "warn — configured but not running";
restart → "ok — running".

* feat(mac-mini-deal-finder): scrape Craigslist for matching listings

Adds skill that scans sfbay.craigslist.org every 15 min for Mac mini
listings matching the owner's criteria (M2+, 16GB+ RAM, 512GB+ SSD,
≤$500, local pickup near 94566) and notifies via SMS + Telegram on
first sight of each new match.

Filters:
- Title must mention "mac mini" (rejects body-only mentions which
  catch too many accessory listings).
- Reject accessory keywords: cord, cable, adapter, hub, case, cover,
  fan, antenna, OEM parts, etc.
- Reject Intel-era listings (i3/i5/i7/i9).
- Require explicit M-series chip (M2/M3/M4) — no soft match without it.
- Soft match for missing RAM/storage when chip is known (sellers often
  omit specs).

Notification: SMS via Twilio REST API (TWILIO_* + OWNER_NUMBER from
.env) + Telegram via results/proactive-{ts}.txt (telegram-bridge picks
it up).

State: state/seen.json dedupes notifications across runs (gitignored).
state/criteria.json is the editable config.

Tested: dry-run on live Craigslist data, 82 listings scanned, 0 false-
positive matches with current filters; the rejection reasons line up
with what a human reviewer would write.

Cron: */15 * * * * — added to skills/schedule-crons/crons.json (the
file is gitignored / personal, so this commit registers the skill but
each user enables it in their own crons.json).

* feat(upstream-sync): daily cron + sync.sh script for pulling sonichi/sutando into amkunte:main

After flipping the fork (amkunte/sutando is now origin, sonichi/sutando is upstream),
periodic syncs are needed to keep this fork's main current with Chi's improvements.
This is the automation: a bash script + daily cron entry that:

- Fetches `upstream/main`
- Stays silent (exit 0) if nothing new or if the working tree is dirty
  (dirty tree happens routinely in this repo — telegram-bridge.py, watch-tasks.sh
  etc. carry long-running uncommitted state; notifying daily would be noise)
- Fast-forwards or merges `upstream/main` → local `main`, then pushes to origin
- On clean success-with-changes: writes results/proactive-upstream-sync-{ts}.txt
  with the diff summary so Telegram surfaces what came in
- On conflict: aborts (leaves main untouched), writes a Telegram notification with
  the manual-resolution recipe

Cron entry in skills/schedule-crons/crons.json fires at 08:07 daily — off-peak
minute, after morning-briefing's 06:57 fire so the morning brief reflects
yesterday's main, sync runs before the workday.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): /amazon dashboard + 6h scan skill

Scans Gmail for Amazon order emails since 2026-01-01 (explicit history
boundary set by owner) and surfaces them at /amazon with current
delivery status. Refreshes every 6 hours.

Architecture mirrors subscription-scanner: agent-driven scan (Gmail MCP
access lives in Claude Code, not Python). The cron fires the prompt in
scan-prompt.md verbatim; the agent runs Gmail searches against
auto-confirm@amazon.com / shipment-tracking@amazon.com /
order-update@amazon.com, dedupes by item title, takes the latest status
email per order (Ordered → Shipped → Out for delivery → Delivered),
writes state/orders.json.

Files added:
- skills/amazon-orders/SKILL.md — schema + behavior
- skills/amazon-orders/scan-prompt.md — verbatim scan instructions
- skills/amazon-orders/.gitignore — excludes state/

Files modified:
- src/web-client.ts — adds renderAmazonOrdersHtml + 3 routes:
  GET /amazon (HTML, sortable table grouped by status, summary cards)
  GET /amazon/data (JSON for client-side polling)
  POST /amazon/scan (queues out-of-cycle scan via task file)

The /amazon page splits orders into 'In progress' (ordered/shipped/
out_for_delivery) and 'Delivered' (collapsible older history). Each
table is independently sortable. Summary cards show Total / In progress
/ Delivered counts. Channel-coloring distinguishes Amazon / Whole Foods
/ Amazon Fresh.

State (orders.json) is gitignored — personal order data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add Price column to /amazon

Owner ask: "add price to the /amazon page table"

The price isn't in Amazon's email subject snippets — only in the body.
Updated scan-prompt.md to instruct future scans to call get_thread on
each 'Ordered:' email and parse 'Grand Total: X USD'. For the existing
49 orders, did this manually (parallel get_thread calls) and populated
total_spent for 41/42 retail orders. Whole Foods / Amazon Fresh confirm
emails don't carry totals — left null for those 7 grocery orders.

Special handling:
- $0.00 grand totals = paid via gift card / rewards points. Display
  as '$0 (gift card)' so it's not confusing.
- 4 of the orders carry value of ~$108-200 paid by gift card; item
  subtotals captured in notes.

Page changes:
- New 'Price' column in both the In-progress and Delivered tables
  (right-aligned, tabular-nums, ascending → larger first when clicked
  for first time)
- New 'Total spent' summary card showing running total across priced
  orders (currently $3,215.99 across 42 orders)
- Sort defaults: dates and price default to descending; text columns
  default to ascending

Schema updates in SKILL.md:
- Added `total_spent: number | null` and `currency: string` fields

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(amazon-orders): add returns_started + refund_issued columns

Tracks return lifecycle alongside delivery for each order:
- 'Return started' — earliest return-request-confirmed date for the item
- 'Refund issued' — latest refund-issued date for the item

Schema gains two new fields per order: returns_started_date and
refund_issued_date (both nullable). Summary card adds a 'Returns'
stat showing N returned with M/N refunded.

scan-prompt.md updated with return@amazon.com query + dedupe rules
for multi-item bundle returns. SKILL.md schema doc updated.

Note: state/orders.json is gitignored (personal data); the in-tree
backfill of 4 known returns lives only on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): accept @username in allowlist alongside numeric ID (#4)

When onboarding new allowed users, the allowlist check only matched
numeric Telegram user IDs. Users typically know their @username (visible
in profile) but rarely their numeric ID (requires API call). This made
/telegram:access allow @Someone silently no-op when admins added a
username — the bridge dropped every message from that user with no
indication that the entry was the wrong shape.

Now load_allowed() values can be either: legacy numeric ID (still
matches sender_id) or @username string (matches username from Telegram
update). The dropped-message log line also gains the id for easier
debugging.

In production via health-check --fix restart since 2026-05-12 per
pending-questions.md; this commit formalizes what is already running.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gitignore): suppress decommissioned tv-price-watcher + .last-pq-set (#7)

Two long-untracked items have been showing up in `git status` every pass
for ~4 days, adding noise to every git-aware proactive-loop step.

- `skills/tv-price-watcher/`: built 2026-05-09 as a single-target watcher
  for the LG OLED77C5PUA during the Memorial Day window. Fired on Amazon
  at $1,650 → owner purchased → decommissioned per build_log entry
  '2026-05-09 ~17:00–19:30'. Cron was deleted; `state/criteria.json`
  has `expires_at: 2026-05-09T00:00:00Z` (already past). Keeping files
  on disk in case the owner wants to revive for Black Friday — just don't
  track or noise up `git status`.

- `.last-pq-set`: runtime state written by
  `src/check-pending-questions.py` to dedupe its hourly notifications.
  Pure cruft; should never have been a candidate for tracking. Same class
  as `.last-pq-notify` which is already gitignored above.

Top of build_log.md explicitly says both '...likely belong in .gitignore.'

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): persist pending_replies to disk across restarts (#8)

`pending_replies` mapped task_id → chat_id only in RAM. Any bridge
restart between an incoming Telegram message and its result file being
written silently orphaned the reply: the result file lands, the bridge
polls, finds no chat_id mapping for the task_id, and either crashes on
`int(owner_id)` (proactive path — fixed in PR #6) or sits inert
(reply path — this fix).

Saw this live 2026-05-13 11:35–11:56 PT: owner's RKLB question
(task-1778697328775) + 'are you awake' ping (task-1778697330159)
arrived; bridge restarted at ~11:51 (startup.sh run, likely triggered
by owner clicking a health-check popup); 11:56 result files referenced
the wiped task_ids → orphaned. Manually drained via direct Telegram
API. Documented in memory/reference_telegram_bridge_restart_loses_replies.md.

This fix:
- New `state/telegram-bridge-pending.json` (auto-gitignored by
  `state/*` rule).
- `_load_pending_replies()` on startup; prunes entries older than 24h
  to avoid unbounded growth + dispatching very-stale replies.
- `_save_pending_replies()` after each add (line ~344) and pop (~406).
  Atomic write via .tmp + rename to avoid partial-file corruption.
- Preserves `added_at` across re-saves rather than rewriting with
  now() (which would defeat the age-based pruning).
- Silent on disk failure — persistence is defensive, not a correctness
  guarantee, so a transient ENOSPC shouldn't crash the bridge.

Trigger for this class of restart is mostly eliminated by PRs #4 + #5
(the dirty-WT diffs that made health-check --fix think the bridge was
stale), so this is a backstop for the remaining low-frequency restart
paths: manual restart, system reboot, process crash, future-code-edit
stale flag, etc.

Test plan (validated via inline round-trip):
- Save → load round-trips the chat_id mapping (int preserved).
- Re-save does not bump `added_at` on entries already on disk.
- Entries older than 24h dropped on load.
- Malformed entries (chat_id not int) dropped on load.
- Atomic-replace: no .tmp leak on success.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(telegram-bridge): regression suite for pending_replies persistence (#9)

Locks in the contract added in PR #8. Without these tests, a future
refactor of the persistence helpers could silently re-introduce the
data-loss window that hit 2026-05-13 11:35–11:56 PT.

Mirrors the style of tests/discord-bridge-state-prefetch.test.py
(standalone runner, exit 0 on pass, no pytest dependency).

10 cases:
- Round-trip preserves chat_id as int.
- Re-save does NOT bump added_at on entries already on disk (otherwise
  the 24h age-prune is defeated by writer activity).
- Aged-out entries (added_at > 24h ago) dropped on load.
- Malformed chat_id (string) dropped on load.
- Missing added_at treated as age 0 → dropped (defensive).
- Non-dict legacy entries (bare-int schema) dropped on load.
- Atomic write via .tmp leaves no leftover on success.
- Load from missing file returns {} (no crash).
- Load from corrupted JSON returns {} (defensive).
- Save survives transient OSError silently.

Run: `python3 tests/telegram-bridge-pending-replies-persist.test.py`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): stale-detect by content hash, not commit-time (#10)

The commit-time-based logic had a hole: an upstream merge that brings
new file content via commits with timestamps PREDATING proc_start would
incorrectly suppress the stale flag.

Concretely observed 2026-05-13: at 12:36 PT I merged 14 commits from
upstream/main into amkunte/sutando main. Chi's PR sonichi#669 (commit a017d93)
added a line to src/voice-agent.ts; commit-time 11:10 PT. voice-agent
process started 11:51 PT. Old check: commit_time (11:10) < proc_start
(11:51) → returns True → voice-agent stayed silently 'ok' while
running pre-merge code. With this fix, the same check correctly flags
voice-agent stale (verified by running the patched health-check
locally — see test_mtime_bumped_content_changed_returns_false).

New strategy:
1. Fast path: if src_mtime <= proc_start, file was never touched →
   trivially unchanged. Returns True.
2. Slow path: src_mtime > proc_start. Use reflog to find HEAD as of
   proc_start, then `git show <old_head>:<path>` and byte-compare
   against current disk content. Equal → idempotent mtime bump (the
   git-checkout no-op case PR sonichi#253 originally targeted). Different →
   real stale.
3. Fail-safe: any git error, reflog gap, or missing file → returns
   False (assume stale). Better to over-flag and force one restart
   than miss a real stale deploy.

Test suite: tests/health-check-stale-detect.test.py — 6 cases
covering all branches. Locks the new contract in place. Mirrors the
standalone-unittest style of the discord-bridge test suite.

All pre-existing tests continue to pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): skip bridges whose env value is obviously a placeholder (#11)

Observed today (twice): a 15-char placeholder DISCORD_BOT_TOKEN sat in
~/.claude/channels/discord/.env for 4 days. After being sidelined to
.env.placeholder this morning, something on the system recreated the
placeholder file 7 hours later (md5-identical content). Health-check
saw .env existing and kept firing 'configured but not running' alerts
hourly because file-existence was the only configuration signal.

This patch adds a length-threshold check: a channel's .env is treated
as 'really configured' only if at least one *_TOKEN key has a value
>= 30 chars. Real shapes:
- Telegram bot tokens: ~45 chars (e.g., '123456:ABC-DEF...')
- Discord bot tokens: ~70 chars (Base64-encoded payload)
- 30-char floor is well below both — never rejects a real credential.

The check applies only to channels whose configuration is via .env
(currently telegram + discord). access.json-only configs aren't
affected (no token to inspect).

Surrounding-quote and whitespace stripping match how python-dotenv
loads values; commented lines (# prefix) are ignored.

Test suite: tests/health-check-skip-placeholder-bridge.test.py — 11
cases covering all edge paths. The contract is replicated as a
top-level helper in the test file (manual sync required if the
inline logic changes). Refactor to a shared helper deferred for now;
the check is small enough that duplicating it is cheaper than
plumbing a shared import.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tmux-status): pass -S /tmp/sutando-tmux.sock so capture-pane finds the session (#12)

scripts/start-cli.sh launches the sutando-core tmux session with a custom
socket path (-S /tmp/sutando-tmux.sock) so Sutando.app can find the same
server across macOS sandbox-altered TMPDIRs. src/tmux-status.ts didn't
pass the matching -S flag, so its capture-pane call hit the DEFAULT
socket at /private/tmp/tmux-501/default — empty — and silently emitted
'no such file' errors once per minute since the feature shipped.

Result: the defensive fallback in effectiveAgentState() that's supposed
to scrape the tmux pane when core-status.json is stale was effectively
dead code (returned 'idle' on every call). Caught today via log audit:
28 [tmux-status] capture failed lines in 2h of web-client.log.

Fix: add SUTANDO_TMUX_SOCKET env override (default /tmp/sutando-tmux.sock,
matching what scripts/start-cli.sh and src/Sutando/main.swift use). Pass
'-S <socket>' before the capture-pane subcommand.

Verified post-fix:
  $ tmux -S /tmp/sutando-tmux.sock capture-pane -t sutando-core -pS -5
  ✳ Schlepping… (2m 32s · ↓ 7.7k tokens · almost done thinking)
  ❯
  ────────────────────────────── sutando-core ──

Now returns real pane content instead of the empty-socket error path.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): Cirrus SR22 deal-hunter skill + /KARTS-AIR live page (#13)

New skill: skills/karts-air/, modeled on amazon-orders + deal-finder.
Scans 4 aviation classifieds daily at 05:00 PT for Cirrus SR22 G2/G3 NA
airframes matching the owner's executive buyer profile (mountain-mission
payload math, CAPS currency, 3-branch avionics evaluation, 5 standing
questions per candidate).

## Files

- skills/karts-air/SKILL.md — skill overview, architecture rationale, cron
  entry, notification format spec.
- skills/karts-air/scan-prompt.md — verbatim instructions the cron passes to
  the agent. Source list, coarse filter, per-candidate LLM evaluation,
  schema for state/karts-air-data.json, when to bail out.
- skills/karts-air/state/criteria.md — owner-editable narrative buyer
  profile (mission, payload, 3 avionics branches, 5 questions, formatting).
  This is the source of truth — agent reads it verbatim.
- skills/karts-air/state/criteria.json — machine-readable filter shorthand
  (model, year range, price range, source URL list). Coarse pre-filter
  before LLM eval.
- skills/karts-air/.gitignore — karts-air-data.json + history snapshots +
  credentials are gitignored (per-user financial / buy-side data).

## Web

- src/web-client.ts new routes:
  - GET /KARTS-AIR — live HTML render from state/karts-air-data.json.
    Shows candidates table (tail, year, price, times, location, branch,
    CAPS), per-row expandable detail (W&B, pros/cons, Q1-Q5),
    new/price-changed highlights, source-status badges.
  - GET /KARTS-AIR/data — same data as JSON for programmatic access.
  - POST /KARTS-AIR/scan — localhost-only owner-tier scan trigger (same
    security shape as PR sonichi#651's /paidsubscriptions/scan: socket-level
    127.0.0.1/::1/::ffff:127.0.0.1 check, scan-prompt referenced by path
    not inlined to avoid header-injection class issues).

## Architecture choice — agent-driven, not Python-scraper-driven

Unlike deal-finder which uses bespoke regex parsers for Craigslist's
stable static HTML, aviation sites are heterogeneous (JS-rendered,
Cloudflare-protected, paywalled). Probing showed: barnstormers/AFS
return generic landing pages without a browser UA; controller returns a
3.7KB JS shell; trade-a-plane 403s. Rather than fight 4 brittle Python
parsers, this skill follows the amazon-orders pattern — the agent uses
WebFetch at scan-time with a structured-extraction prompt. Owner can
grant cookies for paywalled sites to unlock deeper fields later;
deferred sites (ASO, AvBuyer, AeroTrader, FindAircraft) wait on that.

## Cron entry (gitignored crons.json)

```json
{
  "name": "karts-air-scan",
  "cron": "0 5 * * *",
  "prompt": "Run the Cirrus SR22 deal-hunter scan. Read the full instructions in skills/karts-air/scan-prompt.md and follow them verbatim. ..."
}
```

Full entry inlined in SKILL.md for fresh-clone discoverability per Chi
PR sonichi#651 note 1.

Manual invocation: /karts-air (matches existing skill conventions).

Verified: `npx tsc --noEmit` passes, /KARTS-AIR returns HTTP 200 with
empty-state render, /KARTS-AIR/data returns the default JSON shell. First
real candidates arrive after tomorrow's 05:00 PT cron OR an immediate
manual scan via the 'Scan now' button.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-api): write owner-activity on POST /task (#14)

Mirrors what src/telegram-bridge.py:write_owner_activity already does
for incoming Telegram messages, so the proactive-loop's active-engagement
skip rule (b) recognises web/mobile/CLI task POSTs through this API,
not just Telegram and Discord events.

The proactive-loop's skip rule (b) reads state/last-owner-activity.json
to decide whether the owner is in a conversation right now. Without
this fix, agent-api accepts a POST /task, writes the task file for the
watcher, returns 200 — and never touches the activity file. The
proactive-loop firing 30s later sees the activity timestamp from
whenever Telegram last fired and concludes 'owner inactive for hours,'
even when they just sent a task via the web /chat UI.

Observed live 2026-05-13 18:10 PT: owner sent 'how do I access the
chat-like UI' via web — was reflected in tasks/ within ~1 second, but
state/last-owner-activity.json kept showing 'Are you awake' from
11:35 PT (the previous Telegram event). Manually patched the activity
file at the time so this pass's skip-rule check worked. This commit
makes the manual patch unnecessary.

- Add `write_owner_activity(channel, summary)` helper in agent-api.py.
  Atomic .tmp+rename write to state/last-owner-activity.json, same
  schema as telegram-bridge (`ts`, `channel`, `summary` truncated
  to 80 chars). Silent on disk failure — activity tracking is a
  heuristic, not load-bearing.
- Call it from POST /task right after the task file is written.
- Channel field uses `from` from the task body (e.g. 'web',
  'mobile', 'agent-2') so the proactive-loop can later distinguish
  owner-UI events from agent-to-agent automation.

End-to-end test:
  before ts: 1778723092 (10 min ago)
  POST /task with {"from":"test-verify-bug-fix","task":"..."}
  after ts:  1778723803 (just now)
  channel:   test-verify-bug-fix
  summary:   verify owner-activity writes on POST /task

`py_compile` passes. agent-api restarted; `GET /status` returns 200.

discord-bridge.py likely has the same shape and may need the same fix.
Skipped: discord-bridge isn't currently running in this deployment
(its .env is a placeholder; see PR #11 / reference_health_check_placeholder_trap)
so a discord-bridge change wouldn't move the needle today. Worth a
follow-up if/when owner enables Discord with a real DISCORD_BOT_TOKEN.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(karts-air): route case-insensitively so /karts-air also resolves (#15)

Owner reported 2026-05-13 19:11 PT: 'I cannot see anything there. it
just shows the Sutando Web UI' when navigating to localhost:8080/karts-air
(lowercase). HTTP paths are case-sensitive in node:http's matching, so
the route I added at /KARTS-AIR ignored lowercase and mixed-case requests,
which fell through to the default dashboard handler at the bottom of
the request handler.

Fix: normalize url.pathname.toUpperCase() once at the top of the
karts-air block, then compare against the uppercase constants. All three
routes (page, data, scan) now accept any casing — /KARTS-AIR,
/karts-air, /Karts-Air, /KARTS-air — and resolve to the same handler.

Verified post-restart with 4 casings:
  /KARTS-AIR    → 4882 bytes (karts-air page)
  /karts-air    → 4882 bytes (same)
  /Karts-Air    → 4882 bytes (same)
  /KARTS-air    → 4882 bytes (same)

Pre-fix only the first served the karts-air page; the others returned
the 119KB default dashboard.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web-client): write owner-activity on /scan routes (#16)

The three POST /scan endpoints (/KARTS-AIR/scan, /paidsubscriptions/scan,
/amazon/scan) all write owner-tier task files to tasks/ but never bumped
state/last-owner-activity.json. This is the same gap PR #14 fixed in
agent-api.py for the POST /task endpoint — without it, the proactive-loop's
active-engagement skip rule (b) treats the owner as inactive when they've
just clicked "Scan now" in the UI, which can trigger spurious pivot
announcements in #bot2bot.

Adds a writeOwnerActivity() helper that mirrors the Python helpers in
agent-api.py and telegram-bridge.py (atomic tmp + rename, best-effort —
never fail the route on activity-recording errors). Calls it from all
three /scan routes with channel labels web-karts-air / web-paidsubscriptions /
web-amazon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): distinguish pgrep probe failure from "not running" (#17)

The sutando-app and bridge checks in health-check.py used bare `except:`
clauses that collapsed two distinct conditions:
  - pgrep returncode 1 (genuine "no matches found")
  - subprocess.SubprocessError / OSError / unexpected returncode (probe failed)

Both ended up reported as "warn: not running", which then triggered a
self-fired task asking the agent to restart the service or DM the owner.

At 20:21 PT on 2026-05-13 this self-fired a sutando-app task while
Sutando.app had been alive 8h 30min (PID 13536 since 11:51 PT) — pgrep
itself failed transiently. False alarm; took the agent off the queue to
investigate.

Fix: explicitly check `returncode in (0, 1)` before trusting empty stdout.
Catch only `(SubprocessError, OSError)`. On probe failure, surface as
status="ok" with detail "probe failed transiently — assuming alive" so
emit_task_for_failures (which only acts on warn/down/missing/etc.) skips
it. A persistent real failure will still surface — pgrep doesn't fail
twice in a row in normal operation.

Also adds `timeout=5` to the subprocess.run calls so a hung pgrep can't
stall the whole check past one tick.

Tested: existing 26 health-check tests pass. Manual smoke: `python3
src/health-check.py` reports sutando-app as ok (PID 13536 detected
correctly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): distinguish missing access.json from real read errors (#18)

load_allowed() used a bare `except:` that collapsed three distinct
conditions into one silent "deny all" outcome:
  - FileNotFoundError (uninitialized — legitimate, no pairing yet)
  - JSONDecodeError / OSError / ValueError (real fault — should be loud)
  - any other unexpected error (should propagate)

A transient OS error on ~/.claude/channels/telegram/access.json (corrupt
write mid-flight, permission flip, brief disk error) would silently
reject every incoming Telegram message while the bridge process stayed
alive and kept passing the watchdog's process-alive check. Owner-facing
symptom: messages just stop arriving, no signal why. Class A failure —
silent wrong-state assertion masquerading as security default. Same
shape as PR #17's health-check bug, found by sweep for bare-except.

Fix:
- FileNotFoundError → silent empty set (correct for pre-pairing state).
- (JSONDecodeError, OSError, ValueError) → empty set + loud WARN line to
  stdout (lands in logs/telegram-bridge.log) including exception type
  and path so owner can debug.
- Other exceptions propagate.

Security default unchanged: empty set = deny all, which is the right
fail-closed posture. Only the visibility of the failure changes.

Tests: tests/telegram-bridge-load-allowed.test.py covers all 5 cases
(valid config, missing file, corrupt JSON, missing key, permission
denied). Pattern follows repo's duplicate-logic convention with sync
comment per tests/health-check-skip-placeholder-bridge.test.py.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(discord-bridge): loud errors on access.json read fault (parallel to #18) (#19)

Same bare-except silent-failure pattern as PR #17 (health-check) and
PR #18 (telegram-bridge), applied to the three access.json loaders in
discord-bridge.py:
  - load_allowed       (line 380)
  - load_policy        (line 387)
  - load_channel_config (line 394)

All three previously collapsed (missing-file | corrupt-JSON |
OS-error | permission-error) into one silent default outcome. A
transient OS error or corrupt write would silently reject every DM
or treat every channel as unconfigured while the bridge stayed alive.

Fix differentiates the three cases:
  - FileNotFoundError → silent default (pre-pairing legitimate state)
  - (JSONDecodeError, OSError, ValueError) → loud WARN + safe default
  - other exceptions propagate

Security default unchanged: empty allowlist / "pairing" policy / null
channel config are still the fallback. Only error visibility changes.

Discord-bridge isn't running today (placeholder token — see
reference_health_check_placeholder_trap memory), so this fix
lands ahead of need; it'll activate when owner enables Discord. Worth
shipping now: same code-pattern in two parallel bridges should have the
same correctness contract, and a deferred fix tends to drift.

Tests: tests/discord-bridge-access-loaders.test.py — 12 cases covering
all three loaders' valid / missing-file / corrupt-JSON paths plus
edge cases (channel=true open form, missing dmPolicy key, etc).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(karts-air): add Playwright Tier-2 fallback to scan-prompt (#20)

After PR #13 shipped the karts-air skill with a WebFetch-only data path,
the 19:20 PT scan on 2026-05-13 hit Cloudflare/Datadome 403 on all 4
sources. PR investigation confirmed the blocker is fingerprint-grade
bot detection (TLS handshake + JS challenges + IP-type filtering), not
layout fragility — even headless Chromium via src/browser.mjs gets
filtered.

Microsoft's @playwright/mcp@latest was installed via `claude mcp add`
at 19:35 PT (owner request). Real Chromium with full residential
fingerprint defeats the bot detection that blocks WebFetch + curl +
bare-headless. This update gives the scan-prompt a concrete two-tier
recipe so the post-restart session knows exactly which tool calls to
make.

Updates:
- "Tooling: choose tier per source" — explains when to use WebFetch
  (Tier 1, cheap) vs Playwright (Tier 2, residential Chromium).
- Tier-2 recipe — concrete sequence: navigate → wait_for → snapshot →
  console_messages diagnosis → evaluate fallback for DOM extraction →
  close per source.
- First-run Chromium download warning — ~150 MB on cold start, with
  a 60s bail-out rule so cron-triggered scans don't hang.
- Per-source status taxonomy clarified: blocked vs blocked-even-with-
  playwright distinguishes "we didn't try harder" from "we tried as
  hard as we can and still got blocked".
- Bail-out section updated to require BOTH tiers blocked before the
  honest-fail notification.

Owner is about to restart Claude Code to pick up mcp__playwright__*
tools. This pre-baked recipe means the post-restart session can run
the scan immediately without re-discovering tool names. Companion
memory `reference_karts_air_playwright_handoff.md` lists the full
Playwright MCP tool inventory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(karts-air): update SKILL.md sources table with bot-detection reality (#21)

The Sources table claimed "Free" access for all 4 aviation classifieds.
Reality (verified by the 2026-05-13 19:21 PT scan): WebFetch + bare
curl get 403 across the board because all 4 sites use Cloudflare or
Datadome fingerprint-grade bot detection (TLS handshake + JS
challenges + IP-type filtering, not just UA).

A fresh reader of SKILL.md would assume the data is grab-able with
basic HTTP and not understand why the live state shows all blocked.

Updates:
- Sources table column "Access" → split into "Public data" (what's
  free-to-read once you GET the page) and "Bot detection (verified
  2026-05-13)" (what actually happens when an agent tries to GET it)
- Architecture section: WebFetch → Playwright MCP fallback now
  explicitly documented as the two-tier approach (companion to PR #20
  which added the same to scan-prompt.md)
- Rationale for not using a deterministic Python scraper expanded:
  not just layout heterogeneity, also the bot detection that defeats
  Python `requests` without a residential Chromium runtime

No code change. Just doc/reality alignment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telegram-bridge): gate result archival on delivery success + retry cap (#22)

Previously `send_reply()` was a void function that called api("sendMessage", ...)
for each chunk and send_file() for each attachment, without inspecting the
return values. The result-delivery loop in main() archived the result+task
files unconditionally afterwards.

Failure mode: when Telegram returned 429 (rate limit), 502 (gateway), or
network blipped mid-poll, api() returned {"ok": False} silently, send_reply
returned normally, and the result file got archived → owner's reply
silently lost. Class A failure same shape as PRs #17/#18: error-swallowing
masking real failure.

The fix has two parts:

1. send_reply() now returns True iff every api() / send_file() call
   returned {"ok": True}. Exceptions from those calls are caught and
   counted as failure. We don't short-circuit on first failure within a
   single send — we still try every chunk so partial delivery happens
   when possible.

2. The delivery loop in main() now:
   - Peeks chat_id from pending_replies (no pop until success).
   - Increments a per-task in-memory attempt counter.
   - On True → pop, archive, clear counter.
   - On False AND attempts < cap (10) → leave files for next iteration.
   - On False AND attempts >= cap → log loudly, append [DELIVERY-FAILED:N]
     marker, pop, archive. Prevents poison-pill loops on persistent
     failures (chat blocked, auth revoked).

The attempt counter resets on bridge restart, which is intentional: a
restart is a natural retry boundary, and bounding poison-pill loops is
the only job of this counter. Pending_replies persistence (PR #8) means
the task→chat_id mapping survives restarts; this counter doesn't need to.

Sentinel-skip paths ([no-send] / [REPLIED]) keep their original archive-
immediately behavior — those tags indicate "already replied directly,
don't try."

Tests: tests/telegram-bridge-send-reply-bool.test.py — 10 cases covering
happy path, ok=False, non-dict response, exception, chunked send (all
ok / middle fails), file send (ok / fails), text+file partial failure,
blocked-file-path notice. Pattern follows the repo's duplicate-logic
convention. Loop-level retry/cap behavior is small enough to verify by
inspection (file-IO + dict-mutation, no pure-function seam).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): stop claiming success when cp / brew silently failed (#24)

Continuing the silent-failure-pattern sweep (PRs #17, #18, #19, #22, #23).
migrate.sh had two sites where a "✓ X" echo fired unconditionally after
operations that could silently fail:

1. **Bundle-side claude config (line 47):** `cp settings.json`, `cp -r
   channels`, `cp -r skills` all swallowed errors via `2>/dev/null`,
   then `echo "✓ claude config (settings, channels, skills)"` fired
   unconditionally. If any/all failed (permission flip, broken symlink
   pre-empting `cp -r`, source missing), the bundle was shipped with
   missing config and the destination machine booted half-broken with
   no signal back to the operator.

2. **Setup-side brew install (line 142):** `brew install fswatch ffmpeg
   python3 2>/dev/null` swallowed all errors (no network, sudo prompt,
   conflicting versions), then `echo "✓ fswatch, ffmpeg, python3"`
   claimed success unconditionally. Destination proceeded to Step 3+
   with a half-installed system; later steps (watch-tasks, audio
   processing) failed for non-obvious reasons.

Both fixed:

- **Claude config:** track each `cp` via target-existence (not exit
  code — `cp -r` returns non-zero on a single broken symlink like
  `~/.claude/skills/whatsapp` here, even when 45 of 46 entries
  copied fine). Echo lists which parts actually arrived
  (`"✓ claude config (settings, channels, skills)"`) or warns
  loudly if none did.

- **Brew install:** capture exit code via `PIPESTATUS[0]`. Tail brew's
  output through `tail -3` for the operator to see, then emit `✓` or
  `⚠ retry manually: brew install fswatch ffmpeg python3` based on
  the actual return code.

Manual smoke: all three branches of the claude-config detector verified
(all present → list all; partial → list partial; none → warn). Bash
syntax checks pass.

Doesn't fix every `2>/dev/null` in migrate.sh — the rest either gate
their echo on `&&`, or emit `⚠` on failure already (e.g. line 188
`npm install ... || echo "  ⚠ ..."`), or are correct defensive checks
(file-existence probes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate karts-air skill to ~/.claude/skills/ (personal-only) (#27)

The karts-air Cirrus SR22 deal-hunter is a personal acquisition task
for the owner, not applicable to upstream Sutando users. Moving it to
the user-level skills dir (~/.claude/skills/karts-air/) keeps it out
of both upstream (sonichi/sutando) and the public fork (amkunte/sutando)
while preserving the cron + skill discovery patterns that already work
for other personal skills like macos-tools.

Also gitignore .playwright-mcp/ cache dir created by MCP Playwright tools.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(upstream-sync): split main (mirror) from local-main (deployment) (#28)

Two-branch rebase model:
  amkunte/main       mirrors upstream/main EXACTLY (clean, contributable)
  amkunte/local-main deployment branch — personal/WIP commits, rebased on main
                     manually by owner when ready to deploy upstream changes

sync.sh now does only the easy half — fast-forward `main` from `upstream/main`
and push. Defensively checks that `main` has no local commits (rebase model
invariant) and aborts loudly if violated. Never touches `local-main`. Remembers
and restores the starting branch so the cron is safe to run while owner is on
`local-main` (the common case).

Owner brings upstream changes into deployment manually:
    git checkout local-main && git rebase main && git push --force-with-lease

Why this split: Chi rebases PRs before merging upstream, so add/add conflicts
on the same file are common when merging upstream into a deployment branch
that has its own history. The old script accumulated 4+ conflicts/sync and
required owner intervention. New model concentrates conflict resolution at
the manual rebase step where owner can resolve per-commit, surgically.

SKILL.md updated to document the new model and the manual rebase step.

* fix(sms-bridge): use resolve_workspace() instead of repo-rooted parents[1] (#30)

src/sms-bridge.py:40 had:

    WORKSPACE = Path(__file__).resolve().parents[1]

This is the historic anti-pattern that src/workspace_default.py docstring
explicitly calls out — it resolves to the repo dir (~/sutando/) rather than
the workspace (~/.sutando/workspace/). Consequence: sms-bridge polled
repo/tasks for inbound twilio_sms tasks while phone-server (correctly)
writes them to workspace/tasks, so every real SMS task the phone-server
produces was dropped on the floor. Same for results/ and the heartbeat
file (health-check looks at workspace, sms-bridge wrote to repo, hence
the persistent "heartbeat stale" warn even after restart).

Fix: use resolve_workspace() for runtime dirs. Keep REPO_DIR separately for
.env loading, since .env is code-adjacent config, not runtime state. Mirrors
the pattern in src/telegram-bridge.py and every other bridge.

Verified: post-fix heartbeat lands at workspace path; health-check sms-bridge
goes from "running but heartbeat stale 19000s" to ok.

* fix(upstream-sync): write notifications to \$WORKSPACE/results/ not repo root (#33)

notify() was writing relative to \$REPO_DIR; Telegram bridge polls
\$SUTANDO_WORKSPACE/results/ — so push-failure and success notifications
were silently dropped and never delivered to the owner.

Fix resolves WORKSPACE from \$SUTANDO_WORKSPACE (with ~ expansion) or
~/.sutando/workspace/ as fallback, matching the pattern in workspace_default.py.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(security): regression guard for task: field-last invariant

Adds source-grep tests for all 8 task writers fixed in PRs #38–#43.
Each test locates the writer's f-string composition block and asserts
task: appears after security-relevant headers (source, access_tier,
priority). A future writer that puts task: earlier will fail here.

Tests will be red on local-main until PRs #41–#43 are merged; that is
intentional — the tests document the expected post-merge state.

Writers covered: telegram-bridge, discord-bridge, slack-bridge,
github-webhook, agent-api (×3 Twilio handlers), task-bridge/writeChatTask.
conversation-server.ts excluded (already correct, no change needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jun 21, 2026
After rebasing local-main onto main, the task-field-order test guard
(introduced via #44) started flagging writeChatTask in task-bridge.ts
because the function's leading comment contained the literal substring
"task:" — the test's naive substring search matched the comment text
before the actual headers below it.

Two-line fix:
- Rephrase the comment in writeChatTask to refer to "body field" /
  "body marker" instead of `task:` so the substring search no longer
  false-positives on documentation.
- Widen the test's 600→900 char slice for writeChatTask so the actual
  body marker (which sits ~700 chars into the function body) is
  captured.

Tests pass:
- All 8 task-field-order writers verified
- npm test green (39 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
amkunte added a commit that referenced this pull request Jul 13, 2026
Brings local-main current with upstream (merge-base sonichi#1697 → main @62f7767,
146 upstream commits) while preserving local-only features. Conflict
resolutions (policy: take upstream for infra it refactored; keep local
features ported onto the new APIs):

- startup.sh / start-cli.sh: upstream relocated the launcher to
  src/agent/claude/cli/start-cli.sh (sonichi#1891). Took the shim; ported local
  #123 credential-proxy routing (ANTHROPIC_BASE_URL:7846) into the new
  launcher via CORE_ENV_ARGS + export.
- discord-bridge: kept local CHANNELS_DIR (Charlie isolated-instance,
  SUTANDO_DISCORD_CHANNELS_DIR) but repointed its default to upstream's
  claude_home_path(); folded upstream media-headers + write-instrumentation
  into the task writer while keeping task: LAST (security invariant #44).
- telegram-bridge: adopted upstream dict send_reply + obs events, but kept
  local retry-on-transient-failure delivery loop (#8/#22) and 409 multi-host
  backoff (merged with upstream 429 backoff). Fixed auto-merge artifacts
  (duplicated poll_progress + progress_stream import).
- health-check: unioned local notes-symlink + Charlie co-host checks with
  upstream electron-impostor detection; kept sms-bridge coverage on
  upstream's claude_home_path() base.
- voice-agent: kept local dead-air narration; dropped writeVoiceMetrics()
  (upstream refactored it into createSessionRecorder / recorder.flush()).
- github-webhook: moved task: last to satisfy the field-order invariant.

Verified: task-field-order (8 writers) + 18 targeted bridge/health tests
pass; tsc --noEmit clean; all touched Python compiles; shell scripts valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rmm5NP2XFC3n8fYoGP2kYx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant