Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Merge upstream/main (sonichi/sutando) into dev-client - #108

Merged
rickchen007 merged 204 commits into
dev-clientfrom
merge/upstream-main-2026-06-04
Jun 5, 2026
Merged

rickchen007 merged 204 commits into
dev-clientfrom
merge/upstream-main-2026-06-04

Conversation

@rickchen007

Copy link
Copy Markdown

Syncs 203 upstream commits (2327fba..b0a29f0) from sonichi/sutando into the fork. 60 conflicts hand-resolved across src/, skills/, and tests/.

Verification (all green)

  • tsc --noEmit: clean
  • TS tests: 387/387 pass
  • Python tests: 86/86 pass
  • Zero conflict markers, zero unmerged files

208 files changed, +21,565 / −1,700.

Key resolution decisions

Three real merge artifacts caught by the test suite and fixed: an undefined corePath var (get_core_status), a duplicate VOICE_GOOGLE_SEARCH, and a mode/append param mismatch in type_text.

Follow-up (non-blocking)

🤖 Generated with Claude Code

sonichi and others added 30 commits May 19, 2026 19:13
#1 trending dev streak has held three days running. Updating the badge
from a single-day stamp to a range so it stays accurate without daily
edits. Verified today via WebFetch on github.com/trending/developers?since=daily
— sonichi at #1, ahead of Aaron Stannard, Chris Tate, Adrian Hajdin,
Max Lv, et al.

(Background: Mini parked a "Trendshift dynamic badge swap" from ep007
work; investigated tonight — Trendshift has per-developer pages but the
public badge endpoint is repos-only, and the /trending/developers
metric counts lifetime appearances rather than current daily rank.
Doesn't dynamically express what this badge claims. Sticking with
static + range for now; can revisit if Trendshift documents a
daily-rank endpoint or we build our own.)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: sonichi <4250911+sonichi@users.noreply.github.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…ichi#919)

* fix(browser-tools): open_url logs url + rejects whitespace early

Three back-to-back open_url failures today with no observable arg in
logs/voice-agent.log — the prior `catch { return { error: ... } }`
returned the URL in the error string, but voice-agent's stdout strips
result-bodies by the time they reach the log, leaving just
`execution error: Google Chrome got an error: Invalid URL entered. (5)`.
No way to diagnose what URL voice actually passed.

Two changes:

- Trim + reject URLs containing whitespace BEFORE handing to osascript.
  The LLM sometimes passes spoken-URL artifacts ("github . com slash...")
  that Chrome rejects with the opaque (5). Catching them here surfaces a
  clear "URL contains whitespace (got ...)" error to the caller AND logs
  the raw arg so next debug is one log-grep away.

- Add a `console.log [OpenURL] FAILED url=... err=...` to the catch
  block so even non-whitespace failures (e.g. Chrome scripting blocked,
  no front window) leave a diagnoseable trace.

No behavior change for valid URLs. The whitespace rejection is strictly
more permissive than Chrome's silent (5) — we'd have failed in the same
spot, just with a clearer error.

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

* fix(browser-tools): fold Susan's review polish into open_url

Per PR sonichi#919 review feedback:

1. **Zero-width-char reject.** `\s` in JS already matches U+00A0 nbsp +
   U+3000 ideographic space + U+2028/2029 separators + U+FEFF BOM. But
   zero-width characters (U+200B/200C/200D/2060) are NOT in `\s` and
   aren't stripped by `.trim()` — an LLM/voice transcription emitting one
   would slip through and fail opaquely at osascript again. Added a
   separate test for the zero-width set so both classes produce a clear
   error.

2. **Query-string redaction in log lines.** Signed/OAuth URLs flowing
   through would have written full query-param secrets to the logfile
   verbatim. The new `redactQuery()` helper strips `?…` from the log
   side. Error-return path keeps full URL so the user still sees what
   they tried to open (per Susan's review note: redact log, not return).

No behavior change for valid URLs. Both polish items keep error
diagnostics clear while plugging the leak class + the missed
character class.

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

---------

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

Two bugs in the auto-regen output (caught in sonichi#918):

1. **Private-video pass-through.** The script wasn't requesting
   `part=status` on the videos.list call, so playlist items that turned
   `private` (or were always unlisted) came back with title="Private video"
   and got rendered into README as a `[Private video](https://youtu.be/...)`
   link. Fix: request `part=statistics,status`, record `privacyStatus`,
   drop anything that isn't `public` before render_block.

2. **Silent slot shrink.** Hero candidates require >=30 days old AND not
   already in newest. When the playlist had ≤ 4 public videos meeting
   both, hero collapsed and total entries dropped from 4 to 2 without
   warning. Fix: when hero is short, fall back to any remaining unused
   video by likeCount (relaxing the age floor) so total still hits
   NEWEST_SLOTS + HERO_SLOTS where possible.

Plus: if zero public videos are found, exit non-zero instead of writing
an empty list. CI / cron surfaces the error rather than silently
producing a degenerate README.

Smoke-tested locally with synthetic playlists exercising short-hero
fallback, tiny playlist (no over-padding), and the privacy filter.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: sonichi <4250911+sonichi@users.noreply.github.com>
…onichi#923)

Closes the race condition Chi hit 2026-05-20 ~02:18: writing
results/proactive-*.txt to push something to voice always falls back
to Discord-DM via discord-bridge.py:2868 (it polls proactive-* and
DMs unconditionally). If voice happens to be connected at write time,
the proactive file is already gone before voice's session-context can
read it. Result: prolonged confusion when voice "doesn't know the
context" minutes later.

This adds a dedicated voice-only push channel via filename
convention: write to `results/voice-{description}.txt` and:

- task-bridge.ts (this change): when the result-watcher sees a
  `voice-*` file, it spawns `onResult(text)` into the voice session if
  the client is connected. If not connected, the file is LEFT IN PLACE
  — next poll tries again. No discord fallback. After successful
  delivery, the file archives to results/archive/YYYY-MM/ (same path
  as task-* deliveries).

- discord-bridge.py (no change needed): its FALLBACK_PREFIXES tuple
  (line 2981) is ("task-", "question-", "briefing-", "insight-",
  "friction-") — `voice-` doesn't match, so discord-bridge already
  ignores these files. poll_proactive also only picks up
  `proactive-*` — same situation. So this is a task-bridge-only diff.

Use cases:
- Background agent has content for voice to READ or TYPE (LinkedIn
  reply, dictation, walkthrough step). Voice will speak it on its
  next turn or once it reconnects.
- Cross-session "voice should know this when it reconnects" — the
  voice-*.txt sits in results/ until voice picks up; survives
  voice-disconnect cycles cleanly.

Tested locally with a synthetic voice-test.txt: file persists until
client connects, then delivers + archives.

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

* fix(voice-agent): re-check isActive inside the inject timer + add 1 retry

The PR sonichi#923 voice-only channel delivered files reliably to TaskBridge,
but the voice-agent.ts onResult callback dropped them when Gemini
wasn't ACTIVE yet at callback time. 2026-05-20 02:36:44 trace:

  02:36:43.381  Client connected (geminiActive=false)
  02:36:44.123  TaskBridge: Voice-only result: voice-test-1779244500.txt
  02:36:44.125  Delivering result to user      ← onResult fired here
  02:36:44.229  Gemini setup complete           ← active=true 106ms later
  02:36:57.962  Client disconnected

The old code checked `session.sessionManager.isActive` at callback
time, so when `voice-*.txt` lands during the 100-200ms post-connect
window before Gemini setup completes, the check fails, the inject is
skipped, and the result falls through to the disabled Cartesia branch
— never spoken.

Fix: move the isActive check INTO the setTimeout, so it's evaluated
at T+1500ms when Gemini setup is reliably finished. Add one retry at
T+3000ms in case the first check still races (cold-start, slow
Gemini handshake). After the retry, fall back to Cartesia if
enabled, else log and drop.

This is the matching server-side fix for PR sonichi#923's voice-only
channel — the bridge delivers reliably; this makes the inject
delivery reliable too.

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

* fix(voice-agent): always Discord-fallback on stuck-voice, never log-and-drop

Folds Susan's Q3 polish from sonichi#924 review. The prior fallback chain on
the >3s-stuck branch was: Cartesia → log-and-drop. Two problems:

1. **Cartesia only reaches the user if they're watching the web client
   with audio playback live.** A user in a stuck voice session is
   probably looking at the voice surface, not the web UI — the Cartesia
   audio goes into the void.

2. **The no-Cartesia branch is a bare `console.log`.** A genuinely lost
   task result. The whole point of sonichi#923 + sonichi#924 is "voice-only push
   should never silently drop"; a log-only final else regresses that.

Fix: when voice is stuck >3s, always write a `results/proactive-voice-
stuck-{ts}.txt` so discord-bridge DMs the user. Cartesia stays as a
parallel bonus path when available (some users keep the web UI
open). Discord is the reliable backstop.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tmatter entries found (sonichi#806)

* fix(regenerate-memory-index): refuse to clobber non-trivial MEMORY.md when frontmatter scan finds 0 entries (closes sonichi#787)

The cron cross-node-sync fires every 7 min. When SUTANDO_SYNC_PEER is
unset, rsync skips cleanly but regenerate-memory-index.py still runs.
If no per-file frontmatter .md files exist (e.g. the user has only
inline entries in MEMORY.md), the script overwrites MEMORY.md with an
empty body — silently destroying any accumulated inline entries.

The data loss is irrecoverable on machines where the inline entries are
non-trivial and not visible in the loop's system reminder.

Fix: guard the write. When the frontmatter scan produces 0 entries AND
the existing MEMORY.md is non-trivial (has content lines beyond just
headings/whitespace), refuse to write and print a warning instead.
Exits 1 so the cron surface can detect the failure if desired.

Still allows:
- Writing when 0 entries and MEMORY.md is trivial (just a heading)
- Writing when 0 entries and MEMORY.md doesn't exist (fresh install)
- Writing when entries > 0 (normal operation)

Issue also suggests long-term fix: migrate inline entries to per-file
frontmatter automatically. This PR implements the stop-the-bleeding
guard (option 1 from the issue).

* fix(regenerate-memory-index): dry-run shows preview even when clobber guard fires

Per qingyun-wu's review on sonichi#806: the guard was running before the
--dry-run check, so dry-run would exit 1 without showing the 'would
write N entries' preview. Now:

- Guard sets a flag instead of returning immediately
- dry-run always shows the preview, then exits 1 if guard fired
- Normal run exits 1 if guard fired (same as before)
- Also improved error message to suggest SUTANDO_SYNC_PEER
- Added comment clarifying content_lines heuristic

---------

Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
… rollups (sonichi#603 slice 1+3) (sonichi#791)

* feat(conversation-store): sqlite mirror of conversation.log (sonichi#603 slice 1)

Adds time-indexed, searchable sqlite mirror alongside the existing text
conversation.log. Text log remains primary truth; sqlite is a derived
mirror written best-effort in parallel — sqlite errors never propagate.

Uses node:sqlite (built-in, no new dep). WAL mode + 1s busy_timeout for
concurrent writers (task-bridge.ts and voice-agent.ts can run as separate
processes).

Schema:
  CREATE TABLE conversation (
    ts_unix    REAL NOT NULL,         -- floating unix ts, sub-second
    role       TEXT NOT NULL,         -- 'user' | 'sutando' | 'core-agent' | 'SESSION_END'
    text       TEXT NOT NULL,
    session_id TEXT                   -- nullable; future-proofed
  );
  INDEX(ts_unix), INDEX(role, ts_unix), INDEX(session_id, ts_unix)

Files:
  src/conversation-store.ts     — DB helper with lazy init, init-failure latch
  src/task-bridge.ts            — wires logConversation/logSessionBoundary
                                  to parallel-write the sqlite mirror
  scripts/query-conversation.sh — thin sqlite3 CLI with --since / --role /
                                  --last filters; LIKE search

Use case (Susan's primary): time-recall queries — "what did we say
around 5/4 9pm" becomes a single SQL by ts_unix range, instead of grepping
a growing text log.

Slice 2 (follow-up, separate PR): migrate voice/phone metrics tracker
consumers (regression-search, call-diagnostics, dashboard) to read from
SQL. Slice 3: remove the now-redundant voice-metrics.jsonl /
call-metrics.jsonl writers. Slice 4: FTS5 virtual table for stemmed /
phrase search.

Tested: standalone smoke (write 3 rows → sqlite3 SELECT confirms; CLI
LIKE search + --last filter both return expected rows).

Refs sonichi#603.

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

* feat(conversation-store): replace voice-metrics.jsonl + call-metrics.jsonl writers with sqlite

Per Susan ask: stop appending new per-session rollups to the legacy jsonl
files; write to a unified `sessions` table in conversation.sqlite instead.
Existing data/voice-metrics.jsonl + data/call-metrics.jsonl files on disk
are preserved (historical data not lost) — only the writers are removed.

Schema added to conversation-store.ts:
  CREATE TABLE sessions (
    ts_unix          REAL    NOT NULL,
    source           TEXT    NOT NULL,    -- 'voice' | 'phone' | future 'discord-voice'
    session_id       TEXT,                -- voice key
    call_sid         TEXT,                -- phone key (Twilio)
    caller           TEXT,                -- phone caller number
    is_owner         INTEGER,             -- phone access tier (0/1)
    is_meeting       INTEGER,             -- phone is_meeting (0/1)
    duration_ms      INTEGER NOT NULL,
    transcript_lines INTEGER,
    tool_count       INTEGER,
    pending_tasks    INTEGER,
    tool_calls       TEXT,                -- JSON array
    events           TEXT                 -- JSON array
  );

Unified table covers both source types — the few unique fields are NULL
for the other type. Indexed on (ts_unix), (source, ts_unix), (call_sid).

Wiring:
  - src/voice-agent.ts:867 — removed appendFileSync('data/voice-metrics.jsonl', ...);
    now calls recordSession({source:'voice', ...}).
  - skills/phone-conversation/scripts/conversation-server.ts:972 — removed
    appendFileSync('data/call-metrics.jsonl', ...); now calls
    recordSession({source:'phone', ...}).

Best-effort writes; sqlite errors swallowed inside recordSession() — caller
never blocked. Consistent with the pattern already used for the conversation
table (slice 1 in df15a33).

Consumer migration (skills/call-diagnostics/scripts/diagnose.py reading
the jsonls; merge-voice-metrics.sh cross-node merge) intentionally NOT
in this commit — those keep working against the preserved historical
jsonl files for now, scheduled as separate slice 2 PR.

Refs sonichi#603.

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

* chore(sonichi#603): clean up jsonl-support code around removed voice/call-metrics writers

Per Susan: not just stop appending — remove the supporting jsonl code too.
Existing data/voice-metrics.jsonl + data/call-metrics.jsonl on disk are
still preserved (historical data not lost); only the code that revolved
around them is gone or reframed.

Deleted:
  - scripts/test-voice-metrics-reset.sh           (POC for an old reconnect bug fix)
  - scripts/verify-voice-metrics-bug.sh           (one-off verification script)

Updated:
  - skills/call-diagnostics/scripts/diagnose.py — removed the
    "voice-metrics in path → voice; else phone" detection at line 748.
    voice-metrics.jsonl is no longer a fresh data source, so the tracker
    is phone-only now.
  - skills/cross-node-sync/SKILL.md — reframed `data/voice-metrics.jsonl`
    bullet as a frozen-archive note covering both voice/call-metrics.
  - skills/cross-node-sync/scripts/setup-rsync-sync.sh — comments
    reworded to deemphasize voice/call-metrics; replaced with
    subtitle-metrics + conversation.sqlite as primary jsonl/data examples.
  - skills/cross-node-sync/scripts/merge-voice-metrics.sh — script header
    rewritten ("generic jsonl union-merger, named for historical
    reasons"); removed the default voice-metrics.jsonl path args so callers
    must supply explicit paths. Script logic itself unchanged (still
    generic; still serves subtitle-metrics + future jsonls).

Not touched (scoped out):
  - data/voice-metrics.jsonl + data/call-metrics.jsonl files themselves
    (kept as historical archives).
  - merge-voice-metrics.sh filename (rename is housekeeping; can be a
    separate PR if desired).
  - test-merge-voice-metrics.sh tests (still exercise the generic merge
    logic correctly; voice-metrics is just one test case).

Refs sonichi#603.

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

* fix(sonichi#603): mirror phone-server conversation.log writes to sqlite too

Found via Susan's "show me the conversation.log schema" audit: the phone
conversation-server bypasses task-bridge's logConversation() at the per-call
finalization step (lines 944-952) and writes directly to conversation.log,
which meant phone-agent + phone-caller turns landed in the text log but
NOT in conversation.sqlite. Roles user / assistant / core-agent / SESSION_END
were already covered via task-bridge.logConversation; phone roles were the
gap.

Fix: route the same lines through recordConversation(role, text, callSid)
in addition to the existing appendFileSync. Best-effort (sqlite errors
swallowed inside recordConversation, same pattern as the slice-1 wiring).

callSid is passed as session_id so per-call rollup queries can JOIN
conversation rows on session_id=call_sid (both are the Twilio callSid string).

Refs sonichi#603.

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

* feat(sonichi#603): one-shot importer conversation.log → sqlite

Adds scripts/import-conversation-log.py — parses the line-format text
log (ISO_ts|role|text) and bulk-INSERTs into the conversation table.

Use cases:
  - Backfill historical conversation.log into a fresh sqlite
  - One-time migration for users adopting this PR on existing installs
  - Idempotent reset via --reload (TRUNCATEs then re-imports)
  - --dry-run for safe inspection

Tested: 1980 rows (assistant 1306 / user 304 / phone-agent 149 /
phone-caller 136 / core-agent 84 / SESSION_END 1) imported in <1s.
Time-recall query verified: WHERE text LIKE '%phantom%' returned the
5/7 phantom-voice-bug discussion turns instantly.

Refs sonichi#603.

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

* feat(sonichi#603): tool_calls + session_events tables + jsonl backfill importer

Per Susan ask: split the JSON-blob `tool_calls` + `events` columns in
the `sessions` table into dedicated, indexed tables so SQL queries don't
need json_extract for the common per-tool-call / per-event analytics.

Schema (additive — `sessions.tool_calls` + `sessions.events` JSON kept
for cross-version compat and at-a-glance row view):

  CREATE TABLE tool_calls (
    ts_unix, source, session_id, call_sid, name, duration_ms
  );
  CREATE TABLE session_events (
    ts_unix, source, session_id, call_sid, event_name
  );

Both with indices on (ts_unix), (name|event_name, ts_unix), and (session_id, ts_unix).

Live-write fan-out: recordSession() now ALSO populates tool_calls +
session_events from the toolCalls/events arrays in the same call. Per-row
ts_unix derived from each tool/event's own timestamp (falling back to
session end-time if not present).

Importer: scripts/import-session-metrics.py — backfills
data/voice-metrics.jsonl + data/call-metrics.jsonl into all 3 session
tables. Verified on real data: 825 sessions, 3723 tool_calls, 19847 events.

Smoke queries verified end-to-end:
  - "top 5 tools by count + avg duration"  → work(998, 131ms), summon(273, 14.9s), ...
  - "events by name"                        → tool_call:work(1003), session_started:client_reconnect(438), ...

Now possible (vs json_extract'ing the old JSON columns):
  SELECT name, AVG(duration_ms), COUNT(*) FROM tool_calls
  WHERE ts_unix > strftime('%s','now')-86400 GROUP BY name
  ORDER BY 2 DESC;

Refs sonichi#603.

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

* feat(sonichi#603): switch conversation.log readers to sqlite (O(log N) queries)

Per Susan: even with session_id added, SQL is faster than re-grepping
conversation.log on every call. The text log is still primary truth +
tailable; readers move to the indexed sqlite mirror.

New helpers in src/conversation-store.ts:
  - queryLastTurnTs() — latest user/assistant ts since most recent SESSION_END
  - queryRecentTurns(count) — recent rows since most recent SESSION_END

Both use index lookups (idx_role_ts) — O(log N) vs the prior
O(file_size) backwards-walk over the whole text file.

Wired into 3 callers, all with text-log fallback for first-boot / sqlite-init-failure:
  - src/task-bridge.ts:getSecondsSinceLastTurn
  - src/task-bridge.ts:getRecentConversation
  - skills/phone-conversation/scripts/conversation-server.ts:getSafeContext

Validated against the real 1980-row conversation.sqlite — queryLastTurnTs
returns the expected last assistant ts; queryRecentTurns(3) returns the
3 most-recent rows oldest-first matching the prior text-log format.

Refs sonichi#603.

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

* feat(sonichi#603): diagnose.py reads sqlite + sessionId plumbed into logConversation

Two more cleanup pieces tying the sqlite migration together:

1. **diagnose.py → sqlite source**
   - Default load order is now: --metrics flag (back-compat) →
     data/conversation.sqlite `sessions` table → data/call-metrics.jsonl
     archive (last-resort fallback).
   - New --source flag: `voice` (default `phone` for back-compat) or `all`.
     Restores the per-source diagnostic-tracker mode that was lost when
     voice-metrics.jsonl writes stopped (commit 0b32107). voice path is
     fully alive again — recordSession({source:'voice'}) → sessions table
     → diagnose.py --source voice.
   - sqlite row → legacy dict shape (callSid/sessionId/timestamp/events/
     toolCalls) so downstream functions (merge_timeline, diagnose,
     generate_tracker_html) don't change.

2. **sessionId plumbed into logConversation**
   - logConversation(role, text) now accepts optional sessionId,
     forwarded to recordConversation for the sqlite `conversation.session_id`
     column.
   - voice-agent's per-turn write at line 1136 passes SESSION_ID (module-
     level constant). Phone-server already passed callSid (commit 5ac0624).
     core-agent injections stay session-less (NULL) — they don't belong
     to any single owner session.

This closes the slice-5 todo:
  ✓ jsonl writers + supporting code removed (earlier commits)
  ✓ tool_calls + session_events tables + JSON fan-out (de34c4b)
  ✓ import-session-metrics.py backfill (de34c4b)
  ✓ readers (getRecentConversation / getSecondsSinceLastTurn / getSafeContext) (c93d008)
  ✓ diagnose.py sqlite source (THIS)
  ✓ sessionId plumb into new conversation writes (THIS)

Refs sonichi#603.

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

* fix(sonichi#603): address review — text-log-primary readers, reload guard, workspace paths

Addresses sutando#9708's review of sonichi#791:

- conversation-store readers (getSecondsSinceLastTurn / getRecentConversation)
  revert to text-log-primary. The SQLite mirror is a best-effort parallel
  write (errors swallowed) so it can silently lag the log — trusting it could
  return stale data, contradicting the "text log is primary truth" contract.
  Drops the now-unused queryLastTurnTs / queryRecentTurns.
- engines.node -> >=22.5.0 (node:sqlite / DatabaseSync floor); was >=22.0.0,
  which allowed 22.0-22.4 installs that fail at module load.
- DB path resolved via resolveWorkspace() in conversation-store.ts and the
  import scripts + diagnose.py, so the sqlite mirror and conversation.log
  stay in the same tree after the sonichi#831 workspace move.
- import-conversation-log.py / import-session-metrics.py: --reload aborts if
  voice/phone writer processes are live (--force overrides), and wraps
  delete+reimport in one BEGIN IMMEDIATE transaction so it cannot interleave
  with a writer and lose rows.
- conversation index names table-prefixed (idx_conversation_*).

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

* test(init-script): expect workspace-rooted paths after sonichi#913

sonichi#913 moved init.sh's Tier-1 runtime dirs + placeholder files under
$WORKSPACE instead of the repo root, but tests/init-script.test.ts was
not updated — it still asserted the files at SUTANDO_REPO. 7 of its 18
cases fail on main as a result, which sonichi#791 inherited via rebase.

The test now passes SUTANDO_WORKSPACE explicitly and checks Tier-1 dirs
+ placeholder files there. crons.json copy + preflight assertions stay
repo-rooted — those artifacts genuinely live in the repo.

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

* fix(sonichi#603): drop queryRecentTurns import in phone conversation-server

Follow-up to the review-fix commit (4ebcfbe), which removed
queryLastTurnTs/queryRecentTurns from conversation-store.ts (readers
revert to text-log-primary per review finding 1) — but missed that
phone conversation-server.ts also imports queryRecentTurns. tsconfig
only includes src/, so tsc never typechecks skills/, and the dangling
ESM named import would throw at module load and crash the phone server.

getSafeContext() now reads conversation.log directly, consistent with
the same revert applied to task-bridge.ts's getRecentConversation.

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

---------

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

* fix(schedule-crons): remove cross-node-sync from default cron template

Cross-node-sync requires `SUTANDO_SYNC_PEER` + SSH setup between two physical
nodes, plus assumes per-file-frontmatter is the source of truth for memory
entries. Default new-user installs:
- have no peer (single-host)
- start with inline entries in MEMORY.md (auto-memory init: `# userEmail`,
  `# currentDate`)

sonichi#806 added a defensive guard so regen refuses to clobber when 0 frontmatter
entries + non-empty target — but the guard fires only on the *zero-entries*
boundary. Once a user creates one frontmatter file, regen silently drops
remaining inline entries from MEMORY.md.

Per sonichi#758 PR test plan, the `dir-exists-but-zero-frontmatter-siblings` case
was never validated end-to-end (the unchecked @chetanunadkat verify item).

Net: make the cron explicit opt-in via the SKILL.md. Single-host users don't
need rsync-over-ssh; multi-host users add it deliberately when they set up
peer auth.

Refs sonichi#787 (the clobber bug), sonichi#806 (defensive guard, already merged), sonichi#758
(the auto-call wiring that introduced this for new users).

* docs(cross-node-sync): rewrite cron-wiring section for opt-in reality

Per Lucy's review on sonichi#936: SKILL.md previously said the cron-example wires
cross-node-sync automatically via 'cp crons.example.json crons.json'. After
the sonichi#936 removal that's false, so the doc actively misled.

Rewrite the 'Cron wiring' section as a single 'explicit opt-in' block that:
- Explains why it's opt-in (SUTANDO_SYNC_PEER + SSH + per-file-frontmatter
  requirements don't apply to single-host)
- Includes the exact JSON entry operators copy into their crons.json
- Updates the jq verify command (.[] not .jobs[] — crons.json is a top-level
  array, not an object)
…hi#782)

* feat(skills): submit-use-case — OSS-facing use-case submission

Moved from the private sonichi/sutando-skills repo to live alongside
OSS source. OSS contributors install Sutando from this repo; the skill
needs to be co-located so they can actually invoke it.

In one invocation:
1. Opens a `use-case-submission` issue on sonichi/sutando — lowest
   friction, no fork needed.
2. Opens a PR adding `community-use-cases/<slug>.md` with YAML
   frontmatter mirroring the `UseCase` TypeScript type from
   AG2Platform/agent-universe. CLA-gated; submitter gets credit on merge.

Both default-on; `--issue-only` / `--pr-only` opt out.

Reuses the framing-check from the internal `add-use-case` skill
(capability-vs-outcome gate, reject patterns, suggest_reframes).

Critically, **leaves the submitter's git identity alone** so the CLA
signs under THEIR email. Chi-fleet override only kicks in when
`/Users/wangchi/.sutando/workspace/` exists or `SUTANDO_FLEET_OWNER=chi`.

Companion infra:
- Issue template + community-use-cases/ dir convention land in sonichi#781
- Sync skill `promote-community-use-case` stays in private sutando-skills
  (it's a maintainer tool, not OSS-facing) — see sutando-skills #7

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

* fix(submit-use-case): bullets-extend-summary + rename --image-url to --media-url + datetime.now(UTC)

* fix(submit-use-case): SKILL.md flag rename to match script

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… of 'owner' (sonichi#925)

When tierMap exists but a user ID is missing from it, the current code
defaults to 'owner' — silent privilege escalation if the operator forgets
to add a tierMap line when adding a new user to allowFrom.

Split the unmapped-user default:
  1. tierMap absent (pre-tierMap config) → 'owner' (backward compat)
  2. tierMap present but uid missing → 'other' (fail-safe)

Logs a warning when the unsafe-default path fires so operators can fix
their config.

Tests updated:
  - Removed stale .get(uid, 'owner') assertions from raw map tests
  - Added 5 caller-side split-default tests (cases 7-11) covering:
    tierMap-present/uid-missing → 'other', absent tierMap → 'owner',
    mapped uid → correct tier, unknown value → 'other', mixed scenarios

Closes sonichi#893

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…sonichi#926)

The paths filter on pull_request caused doc-only PRs to never trigger
the CI workflow, leaving the required branch-protection check in BLOCKED
state indefinitely (no check reported = never satisfied).

Removing the path filter lets CI run on every PR (~1m cost per run).
This eliminates the BLOCKED-forever class of bug while keeping the
required-status-check safety net active for all PRs.

If CI cost becomes a concern later, the dual-job pattern from sonichi#900
(option 1) can be re-introduced with a skipped-reporting job.

Closes sonichi#900

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…ummary (sonichi#938)

Per @sonichi review feedback on sonichi#928: suppress verbose PASS lines in
CI output so 32+ test files don't flood the log. Only FAIL lines,
summary lines (Results:/passed/failed), and stderr are shown. On
failure, PASS lines are also displayed for context.

No changes to individual test files — the filtering is done in the
CI step itself.

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…hi#931)

Owner safety concern raised 2026-05-19: "if Sutando DMs another user, can
I see what was sent?" Investigation showed Discord/Telegram DMs are
hard-coded to owner (safe), but channel posts via [channel:] redirect
and skill-side fan-outs (wacli/iMessage/Mail/X) currently leave no
visibility trail the owner can audit.

This PR ships phase A: a single append-only `state/outbox.log` populated
by the three running bridges. Phase B (follow-up) wraps the skill-side
fan-outs and adds a dashboard Outbox tab.

What's logged:
- discord-bridge.py — task replies (DM + channel), proactive DMs,
  [channel:] redirects
- slack-bridge.py — _send_reply (DM + channel, prefix-classified)
- telegram-bridge.py — send_reply
- dm-result.py — send_dm (owner DM fallback path)

Format: one JSON-line per delivery with ts / core_id / channel_type /
recipient / body_preview (200-char one-line cap) / body_len /
optional task_id / optional recipient_label. UTF-8 preserved
(ensure_ascii=False).

Design choices:
- append() never raises — observability never blocks delivery.
- Logged AFTER successful send, not before — captures what actually
  went out.
- read_recent() helper for ad-hoc and dashboard consumers; tolerates
  malformed lines so one corrupt entry can't blank the view.

Audit usage:
    tail -f $SUTANDO_WORKSPACE/state/outbox.log | jq .

Tests: 13 new cases in tests/outbox-log.test.py covering append,
UTF-8 preservation, preview truncation/collapse, optional-field
shape, env-fallback for core_id, never-raises-on-bad-workspace,
and read_recent's tail/skip-malformed semantics. Existing
result-markers tests still pass (24/24).

Tracks sonichi#880 multi-core-quality-bar (continuous benchmark + systematic
fixes, no hot patches).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…ect (sonichi#927)

The pool's D7 directive (added 2026-05-19) prepends `**[core: N]**` and an
optional italic `_(...)_` sub-line to every owner-facing reply so chat
clients can see which pool core handled the message. That header lives at
byte 0, which shadowed `_REDIRECT_RE` (anchored with `re.match()`): when a
result body had a D7 header followed by `[channel: <id>]`, the redirect
never fired and the reply went to the original channel — or, when the
original channel had no Slack target, was dropped entirely.

`parse_markers()` now peels the D7 header off before scanning, then
re-stitches it onto the returned body so the user still sees it. Skip
detection runs on the peeled body too, so D7 + skip stays terminal (the
header is discarded with the body — otherwise the bridge would deliver a
header-only message with no content).

All three bridges (slack, telegram, discord) route through this parser, so
this single fix is enough — no per-bridge change needed.

Tested: 6 new cases in TestD7HeaderTolerance cover (a) header + redirect,
(b) header + italic sub-line + redirect, (c) header only / no markers,
(d) header + [no-send], (e) header + [deduped:], (f) header + redirect +
attach. Existing 24 cases still pass.

Builds on sonichi#874.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…tion.log into logs/ (sonichi#940)

* refactor(workspace): relocate root status files into state/, conversation.log into logs/

Within $SUTANDO_WORKSPACE, "current status" files were split inconsistently
between the workspace root and the state/ subdirectory — the same kind of
small, machine-local, frequently-rewritten file living in two places. This
finishes the existing pattern: the workspace root holds only the top-level
directories; every loose status .json file lives under state/.

- core-status.json, voice-state.json, contextual-chips.json,
  dynamic-content.json, quota-state.json → state/
- conversation.log (append-only transcript, not a status file) → logs/

New helpers in workspace_default.{py,ts}: status_path / statusPath (write)
and status_read_path / statusReadPath (read, with a one-release fallback to
the legacy root location). Two new sentinel-gated, non-destructive migrators
(_migrate_root_status, _migrate_conversation_log) wired into resolve_workspace,
modeled on _migrate_inrepo_build_log. init.sh gets a bash twin sweep since it
runs before the Python services.

quota-state.json: credential-proxy.ts now writes state/quota-state.json
directly (was the skill dir); dashboard.py + read-quota.py prefer state/ and
keep the skill-dir path as a last-resort fallback for one release.

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

* refactor(workspace): address PR sonichi#940 review — status-list cross-ref + logs/ mkdir guard

Two non-blocking items from the PR sonichi#940 cold reviews:

- init.sh migrate_root_status_to_state: add a comment pointing at
  _STATUS_FILES in workspace_default.py as the single source of truth,
  so the Python and bash migrator file-lists don't silently drift.
- conversation-server.ts cleanupCall: mkdirSync the logs/ parent before
  appending conversation.log, matching the credential-proxy guard — a
  service started outside init.sh could otherwise hit a missing parent.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…onichi#947)

The "Work Status" recipe used a bare relative `state/core-status.json`.
A proactive-loop / voice / task session runs with cwd = the repo, so the
write lands in `<repo>/state/core-status.json` while every reader
(`health-check.py`, the web UI) resolves `<workspace>/state/core-status.json`
via `status_read_path` in `src/workspace_default.py`.

Result: status writes silently miss their readers — health-check reports a
false "core-proactive-loop stuck / heartbeat > 600s" warn and the web UI
shows stale status, even while the loop is running normally.

Fix the recipe to write the absolute workspace path
(`${SUTANDO_WORKSPACE:-$HOME/.sutando/workspace}/state/core-status.json`),
matching the canonical write location documented at
`workspace_default.py:status_path`. Doc-only change.

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

startup.sh derived `committer.name`/`committer.email` from
stand-identity.json so `git log %cn` showed which fleet host (Mini,
MacBook…) crafted each commit. The feature comment assumed only the
*author* gates CLA-Assistant — but git 2.31+ honors `committer.*` config
natively, so the COMMITTER became `<host>@noreply.sutando.local`, a
non-GitHub identity with no CLA signature.

CLA-Assistant checks BOTH author and committer. Result: every commit
from a fleet host failed the CLA gate — PR sonichi#947 and others were blocked
purely on the committer field, even with a CLA-signed author.

Remove the attribution block. Also actively `--unset committer.*` on
every startup so hosts that already have the stale config in
`.git/config` self-heal on next boot (no manual cleanup needed).

Per-host attribution, if still wanted, belongs in a commit-message
trailer — CLA-Assistant ignores trailers; it does not ignore the
committer identity.

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

SKILL.md step 0 told the loop to write a bare `core-status.json`. With
the session cwd at the repo root, the file lands in `<repo>/core-status.json`
while every reader — `health-check.py`, the web UI — resolves
`<workspace>/state/core-status.json` via `status_read_path`. Same
split-brain sonichi#947 fixed for the CLAUDE.md "Work Status" recipe; this is
the proactive-loop SKILL.md sibling Chi flagged in sonichi#951.

Step 0 now names the absolute path
`${SUTANDO_WORKSPACE:-$HOME/.sutando/workspace}/state/core-status.json`,
matching `workspace_default.py:status_path` and the CLAUDE.md fix.

Sibling check (per sonichi#951): grepped `core-status.json` across
`skills/*/SKILL.md` — only `cross-node-sync/SKILL.md` also names it, and
there it's a sync-exclusion list entry, not a writer. No other bare-path
writer to fix.

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

* docs: release process proposal (qingyun-sutando half)

Plan-only document. Owner asked for a release-process + migration-framework
proposal in #dev. Sutando-Mini and I split it: Mini covers when-to-cut + tag
conventions; this doc covers what-goes-in + CHANGELOG culture + migration
framework + joint engine↔product coupling section.

270 lines covering:
1. CHANGELOG culture — 5 categories, author-curated + release-curator-pruned.
2. Migration framework — numbered migrations/NNNN-*.{sh,py},
   state/schema-version.json, idempotent startup-runner, 1-release
   backward-compat overlap (formalizes the ad-hoc sonichi#876 alias pattern),
   per-migration tempdir tests, 3-phase rollout.
3. Engine↔product coupling — Option A loose coupling (recommended) vs
   Option B lockstep. Engine = sonichi/sutando, Product = Sutando.app.
4. 4 open questions for owner.

Located under docs/ (parallel to docs/workspace-design.md's RFC pattern),
not notes/ (gitignored). Promotion to docs/release-process.md waits on
owner + Chi greenlight and Mini's companion proposal.

No code shipped. Plan only.

* docs(rfc sonichi#906): add Overview + Motivations sections per owner ask

Owner asked in two consecutive Discord messages (task-1779223601794 +
task-1779223627953):
- "Update the RFC to include motivations"
- "Give an overview picture of what we are trying to do, why it matters"

Two new sections at the top of the proposal:

1. **Overview** — TL;DR framing. What (release process + migration
   framework), why it matters (rollback discipline, commercial pinning,
   silent-breakage prevention, bot coordination, continuous gate), what
   it does NOT try to do (time-based cadence, backward migrations, data
   re-encoding, engine-product lockstep), and the co-author split.

2. **Motivations** — 5 numbered motivations (M1-M5):
   - M1: named snapshots of known-good states
   - M2: state-format changes happen often, silently breaking users
   - M3: commercial deploys need a pin point
   - M4: two bots are coordinating, need a shared spec
   - M5: release process IS the migration-test framework

Plus an explicit **Non-goals** subsection listing what we're deliberately
NOT solving here (time cadence, backward migrations, etc.) — anchors
scope for future contributors.

No content change to Parts 1-2 or the joint section; just preamble that
explains why before the design pages dive in.

* docs(rfc): add Mini's half — when to cut + tag conventions

Half of the consolidated release-process RFC (sonichi#906). Covers:
- When to cut a release (feature-completion trigger, owner cuts the tag)
- SemVer vs CalVer (SemVer; engine-vX.Y.Z prefix)
- Tag on main vs release branch (tag on main; no release branch)
- Git tag vs GitHub Release (both — tag is anchor, Release is narrative)
- What to cut next (aligned with qingyun-sutando: cut v0.1.0 now,
  framework as v0.1.1)
- Explicit coupling table mapping every joint surface to the
  corresponding section in qingyun-sutando's half

Plan-only. No code shipped.

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

* docs(rfc sonichi#906): remove commercial framing — OSS readers may not be from AG2

Owner asked (task-1779223950387): "Remove the part about 'Commercial
pinning'. Remember OSS user may not care about commercial things as
they are not necessarily from Ag2 the company."

Changes:
- Overview "Commercial pinning" bullet → "Pinning for anyone building
  on top" (forks, downstream consumers, sister-node fleets — generic
  framing that includes but doesn't assume AG2-commercial users).
- M1 dropped "(commercial waitlist, OSS contributors, sister-node
  fleets)" parenthetical — just "users."
- Removed entire M3 "Commercial deploys need a pin point" motivation
  (was specifically about sutando.ag2.ai install instructions). M4/M5
  renumbered to M3/M4.
- Joint section's bottom bullet reworded: "Customer who hits a bug
  correlates ..." → "Anyone running the bundle who hits a bug ...
  Downstream consumers can pin install instructions to an engine tag."

Per memory feedback_internal_vs_public_docs.md — the sonichi/sutando
repo is PUBLIC; specific paths and commercial framings belong in
INTERNAL (#dev, workspace, owner DM), not in landed docs.

* docs(rfc sonichi#906): owner-quote line removed; M3 restored as downstream-pin-point (AG2-internal facing too)

Owner two follow-ups:

1. task-1779224091789: "Remove wordings like this — Owner (Chi)
   explicitly said 'Plan and make proposals first.'" — the preamble
   quote framing owner's instruction directly was leaking internal
   chatter into a public-repo doc. Replaced with neutral "This is half
   of a co-drafted proposal" framing.

2. task-1779224131237: "M3. Commercial deploys need a pin point —
   This is AG2 internal facing as well." — clarified that the doc
   serves both OSS contributors AND AG2-internal-facing audiences
   (sutando.ag2.ai, packaged bundles, commercial integrators). Restored
   as M3, generalized to "Pin points for downstream consumers and
   bundle deploys" — explicitly enumerates forks, sister-node fleets,
   Sutando.app, AG2-internal deploys, commercial integrators. M4/M5
   renumber accordingly (Two bots coordinating + release-process-is-
   migration-test).

Companion file `docs/release-process-proposal-mini.md` landed in
7d4f260 (Mini's half: when to cut + tag conventions). Both halves now
live on this PR's branch.

* docs(rfc sonichi#906): consolidated doc merging Mini's + qingyun-sutando's halves

Claimed the consolidation step in #dev bot-coord. Merges both source
files (both preserved on the branch as authorship record) into a single
review surface for Chi + Qingyun.

Structure:
- Top-of-doc overview + motivations + non-goals (combined from both).
- Part 1-4 from Mini (when to cut, SemVer + tag namespace, tag-on-main
  no release branch, git tag + GitHub Release + hard/soft gates).
- Part 5-6 from qingyun-sutando (CHANGELOG culture, migration framework
  with numbered migrations/state/runner/tests/phasing).
- Part 7 joint section (engine ↔ product coupling, Option A recommended).
- Part 8 "what to cut next" — both halves landed on (c): cut engine-v0.1.0
  now with manual migration notes, framework as v0.1.1.
- Coupling table verifying no double-coverage between the two halves.
- 5 consolidated open questions for owner (was 4 from each half; deduped
  + added Mini's signed-tags question).
- Next move: owner + Chi review, pick answers, then promote to
  docs/release-process.md + open implementation issues.

Source halves stay on the branch:
- docs/release-process-proposal-mini.md
- docs/release-process-proposal-qingyun-sutando.md

If owner greenlights, this consolidated doc moves to docs/release-process.md
and the two source files can be archived (or kept as authorship record).

No code shipped. Plan only.

* docs(rfc sonichi#906): integrate review-round resolutions

Integrates the 3 reviews on sonichi#906:
- Bassil (cold-review 2026-05-20 06:28) — 4-question picks aligned with
  Chi's later owner-pick; reinforces option (c) cut-now-with-manual-notes
  + framework as next-release primary payload.
- Lucy (review 11:31) — 3 implementation questions on the migration
  framework: multi-core race on schema-version.json, merge-order
  collision on migration numbers, health-check gate on fresh clone.
- Chi (sub-agent draft 19:28 + owner-pick 20:30) — final calls on
  3 open questions: bare v0.MINOR.PATCH (drop engine- prefix),
  working-state when-to-cut (not commit-count), unsigned tags for
  v0.1.x (signed deferred).

Concrete changes in the doc:
- Sweep `engine-vX.Y.Z` → bare `vX.Y.Z` everywhere (frontmatter
  status, body prose, schema-version.json example, alias timeline,
  coupling section, open-question text). 13 occurrences.
- Rewrite "Open questions for owner" as "RESOLVED 2026-05-20"
  section with each pick + reasoning (§§ 4a/4b for when-to-cut
  refinement + GPG deferral).
- §2.7 — replace cut-gate language with Chi's working-state
  framing + clarify health-check scope (tagging install, not
  fresh clone — addresses Lucy Q3).
- §2.8 (new) — Concurrency & ordering: resolve Lucy Q1
  (migrations run as pre-pool step in startup.sh, single-writer-
  by-construction; runner also called from resolve_workspace as
  idempotent fallback for manual sessions) and Q2 (number reserved
  at merge time, pre-merge CI check fails on collision, mirrors
  Rails/Alembic).
- Add "Future hardening" section gathering deferred items
  (signed tags, backward migrations, data re-encoding, PR-template
  migration checklist, docs/install.md fresh-install path,
  docs/release-process.md promotion).
- Rewrite "Next move" to reflect Chi's "mergeable" verdict + the
  Phase 1 framework PR sequence + cut v0.1.0 + workspace contract
  A/B as forcing function for migration 0002.
- Frontmatter: reviewers_integrated field naming the 3 reviews,
  status updated to "open questions resolved; pending merge",
  intro callout quoting Chi's mergeable line.

Plan-only doc — no code shipped.

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

* docs(rfc): sweep consolidated doc to reflect Chi's 2026-05-20 owner-picks

The qingyun-sutando half integrated the review-round decisions, but the
consolidated doc — the file that promotes to docs/release-process.md —
was never swept and still contradicted them. Chi's stated merge bar was
"once the RFC text reflects these, it's mergeable"; this makes it so.

- engine-vX.Y.Z → bare vX.Y.Z everywhere (decision 1).
- §2.2 rewritten: bare tag namespace, no engine- prefix; the namespace
  collision the prefix defended against doesn't exist (product ships
  from its own repo / tag line).
- Part 1.1: add the working-state precondition (decision 4a) — CI green,
  no release-blocker, no half-finished migration; quality gate not count.
- "Consolidated open questions" → "Consolidated decisions (resolved)"
  with all 5 answers recorded inline.
- "Next move" + coupling table + frontmatter status updated to reflect
  that nothing is owner-gated anymore.

Proposal files (mini / qingyun) left as the authorship record per the
consolidated doc's own header; the mini half still reads `engine-` —
flag if it should be swept too.

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

* docs(rfc): sweep mini proposal half to bare vX.Y.Z (consistency with sonichi#906 decisions)

Companion to e248cc6 (consolidated-doc sweep). The mini proposal half
still recommended the `engine-` tag prefix — the option the 2026-05-20
review round overrode. The qingyun-sutando half was already swept to
bare `vX.Y.Z`, so leaving the mini half unswept left the PR internally
inconsistent.

- engine-vX.Y.Z → bare vX.Y.Z throughout.
- §2.2 rewritten: records that this half's engine-prefix recommendation
  was overridden, states the resolved bare-vX.Y.Z decision + rationale.
- Coupling table + open-questions section marked resolved.

Per Chi's "up to you" call on whether to sweep the mini half. All three
release-process docs now consistent with the resolved decisions.

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

---------

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ons (sonichi#954)

The CLI session no longer runs the legacy /proactive-loop. CURATE + ACT
fire from cron entries; /schedule-crons just arms those crons + starts
the task watcher. Same end state, no loop bootstrap needed.

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

Per Lucy's sonichi#954 post-merge review: after sonichi#954 (bootstrap swap
/proactive-loop → /schedule-crons), the watcher no longer started at CLI
startup because schedule-crons step 4 only said "ensure one is running,
prompt the CLI to start it" — passive. The watcher came up only on the
first main-loop cron fire (~5 min later), leaving a startup gap where
voice / Discord / owner tasks queue unprocessed.

Step 4 now mirrors /proactive-loop activation step 2: actively invoke
the Monitor tool with `bash src/watch-tasks-stream.sh`,
`persistent: true`. Existing watcher (pgrep) → skip. Closes the gap.

Note on Lucy's question about crons.example.json missing curate-loop /
act-loop: those reference *personal* skills (~/.claude/skills/
personal-curate-loop, personal-act-loop) which live in private
memory-sync, not the public repo. Fresh public-install correctly
continues to arm `main-loop` → `/proactive-loop`. Not a fix needed.

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

`telegram-bridge.py:313` — `def send_reply(..., task_id: str | None = None)`
uses a PEP-604 union annotation. startup.sh launches the bridge via bare
`python3`, which is /usr/bin/python3 (3.9) on stock macOS. On 3.9 the
`str | None` annotation is evaluated at def-time and raises
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` —
crashing the bridge on import. Telegram has been silently down.

`from __future__ import annotations` (PEP 563) makes every annotation in
the file a lazy string, never evaluated at runtime — 3.9-safe. Same fix
class as the health-check py39-typing work.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `community-use-cases/` entry for the self-healing install use
case — a user's local Sutando clears a false macOS "damaged" flag on a
failed AG2 app install, kills the stale processes, and DMs the developer
the root cause, unattended. Links the @sutando-ai YouTube video
(PTO7dmlOhUg) + the X post. The showcase sync job stages it to
sutando.ai's "Use cases" section on merge.

Outcome-framed per community-use-cases/README.md (no "Sutando can…",
no feature naming). No thumbnail file — the entry carries youtubeId so
the showcase can use the video thumbnail; a still can be dropped at
community-use-cases/thumbnails/self-healing-install.jpg later if wanted.

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

* feat(pointer-teacher): productionize the embodied pointer into Sutando

Brings Clicky's "flying pointer teacher" into the live app. The tracer
proved the loop end-to-end; this folds it in:

- point_at inline tool (src/browser-tools.ts): captures the main display
  via :7845, resolves the Target with gemini-3-flash-preview in Gemini's
  native point format with thinking disabled (ADR-0001 — 1-3px in the
  POC; flash-lite was 108px off, Clicky's raw-pixel prompt 23-69px off),
  writes <workspace>/state/pointer-cmd.json, returns the `say` line +
  an instruction so the voice agent narrates it.
- Registered in inlineTools + ownerOnlyTools (both required arrays).
- PointerOverlayView + setupPointerOverlay/flyPointer/holdPointer
  (src/Sutando/main.swift): screenSaver-level click-through window,
  Clicky bezier flight, beep + pulsing halo, ported verbatim from the
  tracer. Driven by the app's existing DispatchSource dir-watch idiom
  (same as watchResults) on <workspace>/state — resolves IPC open item.

Running inside Sutando.app's real menubar GUI session is the fix for
the "I didn't see any pointer" visibility problem the standalone tracer
binary hit (no GUI-session access when launched from the agent shell).

v1 ships voice-driven; the arming hotkey stays future scope (privacy
property already holds — capture happens only on invocation). AX-first
resolution (open item #3) remains the documented next layer.

Swift compiles with the app's build flags; full tsc --noEmit passes.

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

* fix(pointer-teacher): apply Codex review findings

Codex review of 9896c53 — verdict "ship with fixes". All 5 applied:

- HIGH (main.swift): flyPointer() now invalidates the hold/pulse/fade
  timers from a prior point_at and resets halo/alpha/showLabel, so a
  stale fade can no longer hide a new marker mid-flight. Timers are
  tracked (pointerPulseTimer/HoldTimer/FadeTimer); the fade closure
  takes [weak self].
- MED (browser-tools.ts): atomic IPC publish — write a sibling .tmp
  then renameSync over pointer-cmd.json so the dir-watcher never reads
  a half-written JSON.
- MED: AbortSignal timeouts + res.ok checks on both the :7845 capture
  and the Gemini fetch — point_at is on the inline lane and must not
  hang it.
- MED: per-invocation temp screenshot path + success flag so a failed
  sips can't feed a stale prior screenshot to the model; cleaned up.
- LOW: validate the parsed point is finite and in [0,1000] before
  publishing, instead of reporting success on garbage coords.

Swift compiles with the app flags; full tsc --noEmit passes.

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

* fix(pointer-teacher): match Clicky's real pointer technique + add diagnostics

User showed the marker was a fat 44px triangle inside a big pulsing
halo ring, centered ON the target so it covered what it points at —
nothing like Clicky's. Read Clicky's OverlayWindow.swift and matched it:

- Removed the stroked halo ring entirely. Clicky has none — just a soft
  blue glow shadow (radius 8, grows with flight scale).
- Triangle 44px → 18px (Clicky uses 16; +2 for retina legibility),
  Clicky's exact vertex ratios.
- Rest pose is now a -35° cursor tilt (PointerOverlayView.restAngle),
  not a straight-up triangle — reads as a pointer.
- pollPointerCmd offsets the target +8px right / 12px below the element
  (Clicky's "sit beside it, not on top") + 20px screen clamp, so the
  marker points AT the thing without covering it.
- Mid-flight scale swoop to ~1.3x (Clicky's animateBezierFlightArc),
  settles to scale 1 + cursor pose on arrival.
- Hairline white edge for legibility on dark/light UIs; smaller offset
  label so it never covers the target.

Also added logToFile diagnostics to pollPointerCmd/flyPointer (accepted
ts/coords, raw→target, START/ARRIVED, window visible+level) — this is
how the "not working" report was root-caused to a rendering issue, not
a broken pipeline.

Swift compiles with the app flags; restarted + log-verified end to end.

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

* fix(pointer-teacher): apply Codex review round 2

Second Codex round on this branch (after 0cfb60c), all 4 findings:

- [High] browser-tools.ts: point_at temp file was per-process
  (`pointer-cmd.json.<pid>.tmp`), so two overlapping async calls
  clobbered each other's write/rename. Unique per-call temp name via
  a shared publishPointerCmd() helper (pid+ms+random); atomic rename +
  Swift ts-guard still picks the winner.
- [High] stale-overlay feedback loop: the :7845 server uses the
  `screencapture` CLI (raw framebuffer, ignores NSWindow.sharingType),
  so Codex's sharingType=.none would not have worked. Instead point_at
  publishes {hide:true} and waits ~250ms before capturing; main.swift
  learns the hide command (cancels timers, alpha 0, orderOut) and
  flyPointer re-orders the window front.
- [Medium] display reconfig: overlay was sized once at launch but
  pollPointerCmd maps against the current NSScreen.main.frame. Added a
  didChangeScreenParametersNotification observer + resizePointerOverlay().
- [Medium] POC tracer pointer-overlay.swift: hold() used local
  pulse/hold/fade timers that survived a new fly(). Mirrored the
  production stored-timer pattern so they're cancellable.

TS typecheck clean; both Swift files compile (-O).

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

* docs(pointer-teacher): document POINTER_MODEL + verified deprecation status

gemini-3-flash-preview has no shutdown date on Google's deprecation
page (verified 2026-05-19); the May 25 gemini-3.1-flash-lite-preview
deprecation (sonichi#715) is a different model family. Add the POINTER_MODEL
env override to .env.example as the zero-code-change escape hatch and
record the verified status as design §5 open item 6.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…() (closes sonichi#934) (sonichi#971)

The TS side of the pointer-teacher IPC was resolving the state-file path via
process.cwd() instead of resolveWorkspace(). Works on the launch path (Sutando.app
starts services from the repo root, and the repo-root state/ happens to be the
same APFS inode as workspace/state/ on the dev machine), but silently bifurcates
the moment SUTANDO_WORKSPACE points anywhere else — Sutando.app would read
<workspace>/state/pointer-cmd.json while point_at writes <cwd>/state/pointer-cmd.json,
and the bezier pointer would never fly. No error, just silence.

The Swift side already routes through AppDelegate.workspace (which calls
resolveWorkspace()), so this brings the TS writer in line with the Swift reader.

Vidhu flagged this on sonichi#889 review; non-blocking and tracked as sonichi#934.
* fix(quota): readers use only the canonical workspace path

read-quota.py and dashboard.py both fell back to an in-repo
skills/quota-tracker/quota-state.json when the canonical
<workspace>/state/quota-state.json couldn't be resolved. A stale leftover
copy at that in-repo path (~12h old) silently shadowed the fresh file —
the dashboard's quota panel froze at 4% used with a past reset time while
the credential proxy was writing correct data all along.

Drop the in-repo (skill-dir / cwd) fallbacks. The credential proxy writes
exactly one file; the readers now read exactly that file, or report it
missing — no silent stale shadow.

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

* fix(quota): session-handoff.sh reads canonical workspace quota path

session-handoff.sh read quota from $REPO/skills/quota-tracker/quota-state.json
(in-repo), fallback $REPO/quota-state.json — never the canonical
<workspace>/state/quota-state.json. Same split-brain as read-quota.py /
dashboard.py: a stale in-repo copy shadows the proxy-written file.

Found via the audit Chi requested on PR sonichi#970. Completes the fix class —
all three quota-state readers now resolve only the canonical path.

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

---------

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

Anyone who can speak in the voice channel previously inherited owner-tier
work/file-edit/message-send privileges by default. That's fine for a
single-operator Lounge, but it's a sharp edge to ship as the default —
inviting the bot to any channel with other speakers silently hands them
the proactive-loop surface.

Flip the default to false: non-owner speakers get the read-only tools
only. To restore the single-operator behavior, set DISCORD_VOICE_OWNER=true
explicitly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
sonichi and others added 27 commits May 31, 2026 00:19
…ichi#1366)

`SessionStop` is not a valid Claude Code hook event name. The valid name
is `SessionEnd` (fires on clean session exit). Claude Code 1.x renamed
this event; using the old name silently no-ops the hook (confirmed by
the "Unknown hook event 'SessionStop' was ignored" warning hit by
qingyunwu 2026-05-31).

All references updated in 7 files:
- `skills/catchup-after-startup/scripts/install-hook.sh` (critical — this
  writes the key into ~/.claude/settings.json)
- `skills/catchup-after-startup/{SKILL.md,scripts/catchup-after-startup.sh}`
- `skills/{proactive-loop,schedule-crons,startup}/SKILL.md`
- `src/install-claude-hooks.sh` (comment only)

Existing settings.json files that already have `SessionStop` must be
manually renamed to `SessionEnd` — the install-hook is idempotent and
will add the correct key on re-run if the old key is removed first.

Stand: Echo Act IV Pro

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…onored (sonichi#1367)

`startup.sh` runs `init.sh --auto` at line 37 (and `--preflight` at line 45)
to bootstrap workspace dirs + seed files. `.env` was sourced only later, at
line 85. `init.sh` resolves the workspace via:

    WORKSPACE="${SUTANDO_WORKSPACE/#\~/$HOME}"  (or default ~/.sutando/workspace/)

so when `.env` carries a `SUTANDO_WORKSPACE=/...` override, `init.sh` does
not see it. On any host without a separate `~/.zshenv` export of the same
var, `init.sh` falls back to the default path and `mkdir -p`s + seeds
`build_log.md` / `logs/` / `tasks/` / etc. in the wrong location. After
line 85 sources `.env`, the rest of startup uses the correct path —
leaving orphan dirs in `~/.sutando/workspace/` that no reader touches.

Move the `set -a; source .env; set +a` block above `init.sh --auto`. The
required-key checks (`GEMINI_API_KEY`) stay in their original spot — only
the source moves up. No new behavior; .env is sourced exactly once.

Verified locally:
- `bash -n src/startup.sh` clean
- before/after: `SUTANDO_WORKSPACE` from `.env` is now visible to `init.sh`
  on a clean shell (no `.zshenv` export)

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

* fix(catchup-after-startup): migrate stale SessionStop → SessionEnd in settings.json

Followup to sonichi#1366. That PR renamed every code reference from SessionStop
to the valid SessionEnd event. But users who installed install-hook.sh
before sonichi#1366 merged still carry a dead "SessionStop" key in
~/.claude/settings.json that Claude Code silently no-ops
("Unknown hook event 'SessionStop' was ignored").

install-hook.sh now detects any stale SessionStop entry whose command
references session-handoff.sh, removes it, and (re)installs the
SessionEnd entry with the current resolved REPO path. Conservative:
unrelated SessionStop hooks (if any) are preserved.

Verified locally on a synthetic settings.json carrying both:
- stale SessionStop session-handoff.sh entry (gets migrated)
- unrelated SessionStop echo entry (preserved)
Idempotent re-run on the migrated file is a no-op.

SKILL.md adds a "Migrating from a pre-sonichi#1366 install" section under
the Setup block with the exact re-run command and a one-line verify
snippet so users can confirm the migration landed.

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

* fix(catchup-after-startup): universal SessionStop→SessionEnd rename + auto-heal in catchup

Widens the sonichi#1366 follow-up so the migration covers every stale SessionStop
entry — not just session-handoff.sh — and auto-runs on every /catchup-after-startup
invocation. Users no longer need to re-run install-hook.sh manually after
the rename event; the next fresh session self-heals.

Reasoning: SessionStop is invalid in Claude Code (silently no-op'd with
"Unknown hook event 'SessionStop' was ignored"). Every entry under that
key is dead regardless of the command. PR sonichi#1374's narrow command-match
left users with other stale SessionStop entries (e.g. workspace-sync hooks
installed by separate skills) still broken. The fix is a universal
key-rename: move every SessionStop entry to SessionEnd, drop the
SessionStop key.

Changes:
- new skills/catchup-after-startup/scripts/migrate-settings-hooks.py:
  standalone, idempotent universal migration. Dedupes against entries
  already present in SessionEnd. Quiet no-op when SessionStop is absent.
  Tolerates null / empty / wrong-type / invalid-JSON settings.json.
- scripts/install-hook.sh: replace inline narrow migration with a call
  to migrate-settings-hooks.py, then install the SessionEnd entry as
  before. No behavior change for the install side; broader migration
  coverage for users who choose to re-run the installer.
- scripts/catchup-after-startup.sh: invoke migrate-settings-hooks.py at
  the start of every catchup pass (stderr output, indented; stdout
  briefing stays clean). Auto-trigger paths: SessionStart hook ->
  session-handoff.sh --catchup, /schedule-crons step 0, /proactive-loop
  step 1, manual /catchup-after-startup.
- SKILL.md: refresh the "Migrating from a pre-sonichi#1366 install" section to
  reflect universal scope + auto-run; document the standalone migrator
  for users who want to force the rename without restarting.

Verified locally on synthetic settings.json fixtures covering:
- pre-sonichi#1366 install with session-handoff.sh SessionStop -> migrated
- non-handoff SessionStop (workspace-sync command) -> migrated
- mixed: SessionStop + SessionEnd both have session-handoff.sh ->
  migrated unique, deduped the dup, dropped SessionStop key
- multiple hooks per group -> all moved
- empty / null / wrong-type SessionStop -> cleaned up quietly
- absent SessionStop -> no-op
- invalid JSON -> skipped with stderr warning, file untouched
- re-run after migration -> no-op (idempotent)

Plus end-to-end via catchup-after-startup.sh and install-hook.sh against
a synthetic HOME, confirming the auto-trigger path works.

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

* fix(catchup-after-startup): atomic settings.json writes (Mini's sonichi#1374 review nit)

Per Mini's sonichi#1374 cold review: settings.json writes were non-atomic — if
the python process died between truncate and the final write, the user's
~/.claude/settings.json would be left half-written, breaking every
subsequent Claude Code session that reads its hooks / allowed-tools /
permissions config from there. Tiny window, large blast radius.

Fix: write to a sibling `.tmp` file then `os.replace()` — same-fs atomic
on POSIX (single rename syscall, never half-applied). Crash-during-write
now leaves a stale `.tmp` (harmless) instead of a corrupted settings.json.

Two write call sites covered:
- `migrate-settings-hooks.py`: extracted `_atomic_write` helper, used at
  both write sites (cleanup path + migration path).
- `install-hook.sh`: inline python heredoc grew a local `atomic_write`
  closure (small + bash-friendly; kept inline to avoid a second .py).

Re-ran the 10 fixture cases from the previous commit + the end-to-end
install-hook test on a synthetic HOME — all pass, no `.tmp` leftovers
on success.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s prompt fix) (sonichi#1373)

Effectively reverts the prompt-side mitigation introduced in commit
056b1c5 ("add SPEECH REQUIREMENT rule to suppress [Silence] bug",
2026-05-30 16:21 ET). That mitigation embedded verbatim fallback
phrases ('Yes, I'm here.' / 'I hear you — what next?') in the system
prompts of guided-setup / pair-debug / pair-review-code; Gemini Live
used them as canned response templates and emitted the same sentence
on every ambiguous user turn — the canned-loop bug Susan hit on
2026-05-30 22:25Z, 03:45Z (post-restart), and 04:46Z (post-B1-guard
restart).

Open follow-up: the [Silence] bug (sonichi#1356, ~77% silent-turn rate) was
the original motivation for 056b1c5. Removing the prompt fix likely
brings [Silence] back. The real fix for both is a code-level guard
in src/voice-agent.ts (dup-detection + ctrl46 / [Silence] filter on
outgoing Gemini text) — same class of defense that voice-agent has
always been missing. That is tracked separately.

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

* fix(startup): source .env before init.sh so workspace overrides are honored

`startup.sh` runs `init.sh --auto` at line 37 (and `--preflight` at line 45)
to bootstrap workspace dirs + seed files. `.env` was sourced only later, at
line 85. `init.sh` resolves the workspace via:

    WORKSPACE="${SUTANDO_WORKSPACE/#\~/$HOME}"  (or default ~/.sutando/workspace/)

so when `.env` carries a `SUTANDO_WORKSPACE=/...` override, `init.sh` does
not see it. On any host without a separate `~/.zshenv` export of the same
var, `init.sh` falls back to the default path and `mkdir -p`s + seeds
`build_log.md` / `logs/` / `tasks/` / etc. in the wrong location. After
line 85 sources `.env`, the rest of startup uses the correct path —
leaving orphan dirs in `~/.sutando/workspace/` that no reader touches.

Move the `set -a; source .env; set +a` block above `init.sh --auto`. The
required-key checks (`GEMINI_API_KEY`) stay in their original spot — only
the source moves up. No new behavior; .env is sourced exactly once.

Verified locally:
- `bash -n src/startup.sh` clean
- before/after: `SUTANDO_WORKSPACE` from `.env` is now visible to `init.sh`
  on a clean shell (no `.zshenv` export)

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

* fix(agent-registry): grep .env for SUTANDO_WORKSPACE when env var is unset

The SessionStart hook (`hooks/session-start.sh`) is the canonical caller of
`registry-client.py` and it runs in a fresh shell that Claude Code spawns —
that shell does NOT inherit env from `bash src/startup.sh` (which sources
`.env` later). So on hosts whose `.env` carries:

    SUTANDO_WORKSPACE=/Users/me/Documents/sutando-workspace

but whose `.zshenv` does NOT export the same var, `os.environ.get(...)`
returns None and `resolve_workspace()` falls back to `~/.sutando/workspace/`.
The registry then writes `state/agent-registry.json` + `data/agent-registry.db`
into the wrong directory, while readers (Electron overlay, dashboard,
proactive-loop) look in the right one. Result: the registry appears empty
even when agents register every session. Completely silent — no error.

Resolution order is now:
  1. `$SUTANDO_WORKSPACE` from process env (unchanged)
  2. NEW: grep the repo's `.env` for `SUTANDO_WORKSPACE=` via bounded
     parent-walk from this module's resolved path. Stripped of quotes,
     tilde-expanded. Symlink-safe (realpath() first so a symlink at
     `~/.claude/skills/agent-registry/` is followed into the repo).
  3. `~/.sutando/workspace/` fallback (unchanged)

Step 2 emits a one-line stderr notice when it fires, so operators see when
the env var is missing in their bootstrap chain rather than discovering the
silent miss months later via "why is the registry empty?".

DRY: both registry-client.py and registry-service.py used to duplicate
`resolve_workspace`; lifted into a shared `_workspace_resolve.py` so the
two scripts can't drift apart.

Test matrix (5 scenarios, all pass):
- env var set → use it (no warn)
- env unset + .env has override → grep value (warn fires)
- env unset + no .env → default ~/.sutando/workspace (no warn)
- env empty string → treated as unset (grep value, warn fires)
- env value with tilde → expanded

Companion to sonichi#1367 (same bug class in src/startup.sh).
Other call sites that read SUTANDO_WORKSPACE without a .env fallback
(src/workspace_default.py, src/workspace_default.ts, src/init.sh) have
the same silent-miss risk and warrant a separate sweep.

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

---------

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

When a process resolves the workspace without `SUTANDO_WORKSPACE` in
its env but `.env` defines the var, the fallback silently lands in
`~/.sutando/workspace/` while the rest of the fleet uses the override.
Two services with the same `.env` then split-brain — one writes to
`~/.sutando/workspace/state/agent-registry.json`, another reads from
`.env`-specified `state/agent-registry.json`. The bug is invisible:
no error, just empty/stale state.

Adds a one-line stderr warning that fires once per process when the
fallback masks a .env override. The actual resolution behavior does
NOT change — callers who genuinely want the default still get it
(no .env override → no warn → fallback as before).

Applied to all three workspace resolvers:
- `src/workspace_default.py` (`resolve_workspace`)
- `src/workspace_default.ts` (`resolveWorkspace`)
- `src/init.sh` (inline `WORKSPACE=...` block)

The Python and TS resolvers add a small `_grep_env_for_workspace()`
helper that walks up from the module file to find `.env` and reads
the `SUTANDO_WORKSPACE=` line (best-effort, never raises). init.sh
uses inline `grep | cut | sed` against `$REPO/.env`. Behavior is
parity across all three.

Companion to:
- sonichi#1367 (startup.sh ordering — moved .env source before init.sh)
- sonichi#1368 (agent-registry — auto-honors .env when env var missing,
  Option B; this PR is Option C "loud warn" from the discussion)

Tier 1A of the prevention plan owner greenlit:
  Tier 1A: loud-warn (this PR)
  Tier 1B: clean-shell CI smoke test (separate PR, follow-up)
  Tier 2:  unified `scripts/source-env.sh` helper + manifest (later)
  Tier 3:  drop default fallback entirely (long-term)

Verified locally across 6 scenarios:
- env var set → no warn (PY/TS/SH)
- env unset + .env override → WARN fires (PY/TS/SH)

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

* docs(wire-list): channel-wide sourcing, 3 hero slots, auto-dedup vs README

Source the WIRE list from every public upload on the @sutando-ai channel
(resolved via the seed playlist's owning channel) instead of the curated
playlist, so the list self-maintains. Changes:

- HERO_SLOTS 2->3, HERO_MIN_AGE_DAYS 30->7 (faster hero rotation)
- Drop the standalone "Earlier episodes ->" playlist link slot; the
  @sutando-ai channel + WIRE playlist links above the block already cover
  "see more". Slot 5 becomes a 3rd hero spot.
- Auto-dedup: never list a video already linked elsewhere in README
  (embedded demo, etc.) by scraping YouTube IDs outside the marker block.
- Remove the now-redundant "Recent capability proofs" README section; those
  episodes flow through the channel-wide hero ranking instead.

The rendered block regenerates via the scheduled workflow (holds the API
key); this PR carries the logic + the section removal only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stand: Echo Act IV Pro

* docs(README): Berkeley Vision talk + agentic presentation thumbnail grid

Replace plain `📺 Watch the vision talk` link with a 2-column thumbnail
table alongside the inline hero video:
- Berkeley Vision talk (youtu.be/c39fJ2WAj6A?t=7719)
- The AI agent that presented live (youtu.be/tisE8YjmLdU)

The agentic presentation was removed from "Recent capability proofs" in this
PR's parent; restoring it here as a persistent thumbnail. Both render as
clickable YouTube thumbnails on GitHub (static image, not inline player).
The regen-wire-list dedup logic already excludes these IDs from the WIRE block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stand: Echo Act IV Pro

* docs(README): add ep007 to video thumbnail grid (3-column)

Add WIRE ep.007 ('50 days. 600+ PRs. #1 trending dev.') as the first
column, before Berkeley Vision talk and agentic presentation. Grid now
has 3 clickable YouTube thumbnails below the inline hero video.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(README): reorder thumbnail grid — agentic presentation first

New order: agentic presentation → ep.007 → Berkeley Vision talk.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(README): regen WIRE list — channel-wide sourcing, 5 slots, dedup active

2 newest + 3 hero from @sutando-ai channel. Embedded demo and thumbnail-grid
videos automatically excluded by readme_excluded_ids().

Stand: Echo Act IV Pro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(regen-wire-list): extend YT_ID_RE to cover youtube.com/embed/ URLs

YT_ID_RE now matches youtu.be/, youtube.com/watch?v=, and youtube.com/embed/.
The previous pattern missed embed-form URLs; if a future README addition uses
an embed URL, that video could have leaked into the WIRE block. Cheap defense.

Observation: Susan's review of sonichi#1351.
Stand: Echo Act IV Pro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…king (closes sonichi#1235) (sonichi#1237)

* fix(phone-conversation): archive task + result files instead of unlinking (closes sonichi#1235)

The phone-conversation skill's in-call poll loop consumed result files
via `unlinkSync(resultPath)` and never touched the task file in tasks/.
Per sonichi#1235, this leaves `task-phone-*.txt` files in tasks/ indefinitely,
breaking the audit-trail pattern Chi quoted on 2026-04-18 ("instead of
deleting we should archive the tasks. It can be useful for self-
improving") that AG2 Space / Discord / Telegram already follow via
src/task-bridge.ts:archiveFile().

This commit:

- Adds `archivePhoneFile(srcPath, kind, taskId)` helper at the
  top-level of conversation-server.ts that mirrors task-bridge.ts's
  archiveFile() — moves the file to <baseDir>/archive/YYYY-MM/<taskId>.txt
  via renameSync, falls back to unlinkSync on failure (cross-device,
  permission, etc.) so stale files never accumulate.
- Replaces the single `unlinkSync(resultPath)` call in the result-
  consume branch (~line 401) with two archive calls — one for the
  result, one for the task.
- Adds the same task-file archive call in the POLL_TIMEOUT branch
  (~line 411) so long-running tasks that exceed POLL_TIMEOUT_MS also
  get cleaned up. (The work may still complete async on the core
  side, but the call's polling window is closed.)
- Adds `renameSync` to the node:fs imports.

## Tests

7 structural assertions in `tests/conversation-server-archive-phone-tasks.test.ts`:

- renameSync imported from node:fs
- archivePhoneFile helper defined with the right signature
- destDir computed as <baseDir>/archive/YYYY-MM
- helper falls back to unlinkSync on rename failure
- result-consume branch archives both result + task (not just result unlink)
- result-consume branch no longer matches the pre-fix `try { unlinkSync(resultPath); } catch {} // Cache result` pattern
- ≥2 `archivePhoneFile(taskPath, 'tasks', taskId)` call-sites total (result-consume + POLL_TIMEOUT)

All pass.

## Repro confirmation

On the mini node where sonichi#1235 was diagnosed: 3 lingering `task-phone-*.txt`
files in tasks/, 0 in tasks/archive/ (despite many calls historically).
After this fix lands + a deploy cycle, new phone tasks should land in
tasks/archive/YYYY-MM/ alongside AG2 Space / Discord / Telegram tasks.

Refs sonichi#1235.

* fix(phone-conversation): close hang-up branch leak + clarify comments (review-follow-up to sonichi#1237)

Addresses review feedback on sonichi#1237 from @VasiliyRad and @liususan091219:

1. **Hang-up early-return branch (Vasiliy's gap)** — the poll's
   `callSession.hangingUp || !activeCalls.has(...)` early return left
   the task file behind. Added archivePhoneFile(taskPath, 'tasks', taskId)
   there too. Now all 3 poll-exit paths archive: result-consume +
   hang-up + POLL_TIMEOUT. Closes sonichi#1236's gap (which Bassil's now-closed
   PR also flagged).

2. **archivePhoneFile rationale comment (Lucy's #1)** — original comment
   said "cross-device, permission, etc." but `tasks/ → tasks/archive/`
   is same-filesystem by construction, so EXDEV won't fire. Rewrote to
   "renameSync failed for ANY reason (ENOENT race / permission /
   disk-full)" — more honest about the actual failure modes.

3. **POLL_TIMEOUT branch comment (Lucy's #3)** — added explanation that
   we DON'T archive the result file here even though the work may still
   complete async on the core side, because the canonical result-
   watcher in src/task-bridge.ts will archive `results/<task_id>.txt`
   independently. Prevents a future reader from adding redundant
   result-archive logic.

## Test update

Bumped the call-site count assertion from ≥2 to ≥3 to lock in the new
hang-up archive path. All 7 tests still pass.

## Stays out of scope

- Lucy's non-blocking #2 (shared `src/file_archive.ts` so phone-skill
  and task-bridge share one canonical helper) — explicitly a separate
  cleanup PR per the review.
- Lucy's non-blocking #4 (integration tests vs structural regexes) —
  no behavior change adds; existing tests pin the file-move semantics.

Refs sonichi#1235, follow-up to sonichi#1237.
…sonichi#1380)

* cleanup: move community-use-cases to docs/, remove root /logs and /data

Repo-root cleanup: top-level directories that aren't agent runtime
or canonical doc paths should not live at the repo root.

- **community-use-cases/ → docs/community-use-cases/**: this is
  user-facing reference material. Internal paths in README updated
  to match the new location (sample slug paths, thumbnail/video
  paths). Tracked files moved via git mv (rename detection preserves
  history).
- **logs/.gitkeep**: removed. logs/ was a placeholder for runtime
  output that now lives under \$SUTANDO_IN_REPO_WORKSPACE/logs/ per
  the workspace contract. No code references the root path.
- **data/radar-topics.example.json**: removed. The example data file
  was unreferenced. Skills owning their own example data should
  ship it under skills/<name>/ (none currently do for radar topics).

No code touched — pure file-tree reorganization. Verified no source
references the moved/removed paths.

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

* chore(submit-use-case): update paths to docs/community-use-cases/

Followup to the community-use-cases → docs/community-use-cases move.
The submit-use-case skill (which writes new use cases via PR) carried
the old path in:

- skills/submit-use-case/README.md (2 refs in user-facing docs)
- skills/submit-use-case/SKILL.md (4 refs incl. description + schema header)
- skills/submit-use-case/scripts/submit_use_case.py (5 refs: header
  docstring, render docstring, --dry-run label, target_dir clone path,
  existing-slug error message, git add path)

Conventional-commit scopes (\`feat(community-use-cases): submit X\`) kept
unchanged — those are topic identifiers, not file paths.

Closes the audit gap from PR sonichi#1380 review.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ne accuracy (closes sonichi#1357) (sonichi#1363)

* fix(call-diagnostics): schema-drift on sessions SELECT (closes sonichi#1357)

PR sonichi#791 introduced a SELECT against `sessions.tool_calls` and
`sessions.events` — columns that don't exist on the current sessions
schema. The query failed silently (stderr-only "sqlite load failed"),
the script reported "No calls found" against a workspace that
contained 226+ phone calls, and the detect-and-repair pipeline was
inert.

Per Mini's suggested minimum diff (issue body):
- Drop the two non-existent columns from the SELECT.
- Reconstruct `events` per-row by querying `session_events` (joined
  by session_id, falling back to call_sid for older rows that had
  only one populated).
- Reconstruct `toolCalls` per-row from the source-specific
  transcript table (`phone` / `voice` / `discord_voice` where
  `kind = 'tool_call'`) — tool name in `text`, duration in
  `duration_ms`. Hard-coded table whitelist to avoid SQL injection
  on the source string.

Local smoke test (Mac Studio workspace, 226 phone calls):
  python3 skills/call-diagnostics/scripts/diagnose.py --all
  → finds 226 calls, produces hallucination / auto-play /
    STT-lag detection output with concrete evidence ratios

Issue filed by Echo Act IV (Mini) on Mac mini.

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

* fix(call-diagnostics): reconstruct caller/sutando/tool_call timeline so detectors actually fire (sonichi#1357 review)

Echo's review (sonichi#1357) found the prior fix made the sqlite load succeed but left
every detector blind: _load_session_events rebuilt only from session_events,
which deliberately omits user/agent/tool_call/tool_result rows — exactly the
`caller:`/`sutando:`/`tool_call:` prefixes diagnose()'s 7 detectors key off.
Also: source stored `discord-voice` vs the helper's `discord_voice` whitelist;
phone rows key on call_sid (session_id null on the sessions row).

Fixes:
- normalize source `discord-voice`->`discord_voice` before surface-table lookup
- _load_session_events now ALSO reconstructs caller:/sutando:/tool_call: events
  from the per-source surface table (user->caller:, agent->sutando:,
  tool_call->tool_call:<name>), merged with session_events lifecycle events
- match session_id OR call_sid in both helpers (phone uses call_sid as the key)

Verified on a real phone call: events 0 -> 139 with caller(27)/sutando(29)/
tool_call(28); diagnose() now runs and surfaces 23 issues (was blind).

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

* fix(phone-conversation): record phone tool_calls under callSid, not undefined sessionId

conversation-server.ts passed callSession.sessionId to recordToolCall('phone', ...),
but CallSession has no sessionId field -> the phone tool_call rows were written with
session_id=NULL. diagnose.py's session_id-OR-call_sid loader (this PR) then can never
join them, so phone toolCalls came back empty (Echo, sonichi#1357 review: 4 NULL rows, CAfcc
returned 0 despite legacy tool_call:work events). Use callSid, consistent with the
recordConversation(role, text, callSid) user/agent writes. Closes the phone half of the review.

* fix(call-diagnostics,phone): address Echo's timeline-accuracy review

Finding 1 (phone end-of-call timestamps): move the phone recordConversation
sqlite write from the end-of-call cleanup batch into the real-time turn handler,
so each utterance gets its own ts_unix instead of all phone rows landing at
call-end (which broke diagnose.py's reconstructed timeline ordering). Cleanup
now only mirrors to conversation.log — no duplicate phone-table rows.

Finding 2 (tool_call timestamps): surface tool_call rows are written at result
time with duration_ms; diagnose.py now synthesizes tool_call: at
(ts_unix - duration_ms/1000) and tool_result: at ts_unix, so detectors see
execution-start ordering + a matching tool_result to suppress hallucination
warnings.

Verified: tsc clean, py_compile clean; live smoke on a real voice session
(1010 tool_call: + matching tool_result: pairs, ~48ms apart = the duration).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… modes (closes sonichi#1375) (sonichi#1365)

* fix(screen-companion): keep `work` + `switch_mode` reachable in all modes (closes sonichi#1375)

activate_screen_companion rebuilt the restricted tool surface from inlineTools
only, silently dropping the prepended mainAgentTools (work, switch_mode,
get_task_status, save_meeting_note) — so the agent could not delegate work
(post a comment, file an issue) inside any screen-companion mode.

Fix: filter the full session surface (vision-tools getFullToolSurface()) instead
of inlineTools alone, and add 'work' to ALWAYS_RETAIN. Falls back to inlineTools
when no session updater is registered (phone/tests), preserving prior behavior.

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

* fix(screen-companion): prompt overlays — delegate via work when asked + quote on-screen identifiers verbatim (sonichi#1375)

The tool-surface fix alone wasn't enough: the mode overlays framed companion
mode as read-only and never mentioned `work`, so the agent refused to act even
with `work` available. Add a DELEGATING block to all 3 mode configs:
- clarifies the "unprompted" limits are about not acting on own initiative, not
  a ban on acting when the user explicitly asks
- when asked to do a discrete task (post comment / file issue / edit / send /
  research), call `work` to delegate to the core agent
- quote on-screen identifiers VERBATIM (exact case/spelling, e.g. NOTES_DIR not
  notes_dir); vision_query if unsure (prompt-layer of sonichi#1377)

Verified live: agent now fires work from pair-review-code and the delegated
comment landed on sonichi#1370.

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

* docs(screen-companion): SKILL.md — correct the always-retained tool set (Echo review nit, sonichi#1375)

SKILL.md said the restriction retains 'two always-retained tools'
(activate / deactivate). It is now four — ALWAYS_RETAIN =
activate_screen_companion / deactivate_screen_companion / switch_mode / work —
and the restriction filters the full session surface (getFullToolSurface()),
not just inlineTools, so non-inline mainAgentTools survive.

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

* fix(screen-companion): activation payload exposes effective_tools (incl. work)

Per Mini's review on sonichi#1365: the activation `_note` told Gemini to restrict
itself to tools_allow + mode-exit tools, which omits `work`/`switch_mode` —
contradicting a config overlay that instructs calling `work`. The restriction
filter already retains ALWAYS_RETAIN (work / switch_mode / activate / deactivate)
regardless of tools_allow, so the surface was fine; only the instruction text
was self-contradictory.

Adds an `effective_tools` field (tools_allow ∪ ALWAYS_RETAIN) to the activation
return and rewrites `_note` to point at it, naming `work` as available even when
not in tools_allow. No behavior change to the enforced surface.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…onichi#1382)

- `## 🎬 Recent autonomous output` → `## Sutando in action`
- Removed `**Sutando WIRE** — ...` opening (section covers more than WIRE)

Stand: Echo Act IV Pro

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…1020 (sonichi#1352)

* docs(email-find): address @qingyun-wu's 4 review points from sonichi#1020

- Rule 4: clarify MINIMAL omits message *bodies* (not messages)
- Phase 1: anchor query to (to:me OR from:me) — covers sent mail too
- Phase 2: spell out DOMAIN is the bare domain, not *@wildcard form
  (Gmail from: rejects *@ — Phase 2 was silently no-opping in literal substitution)
- Partner-domains table: drop first_seen/last_useful/pruned_at bookkeeping
  (heavy protocol applied inconsistently turn-to-turn; stale row only costs one extra query)

All 4 points raised by @qingyun-wu in sonichi#1020 (review)
but unaddressed before sonichi#1020 merged. No code touched; docs-only follow-up to one skill file.

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

* docs(email-find): drop reviewer-attribution from SKILL.md frontmatter note

Per Mini's review on sonichi#1352: the line carried a PR-review provenance reference
('Qingyun's point — ...') in user-facing docs, which ages poorly and doesn't
match the surrounding docs style. Rewrites to keep the substantive point
('heavy protocol for low payoff') without the reviewer attribution.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…chi#1394)

Closes the latent ambiguity in type_text where the caller had to manage
selection state (composing press_key('cmd+a') + type_text) for replace-all,
or the paste would land at the wrong place (mid-paragraph caret splice).
The LLM did this composition unreliably — the 2026-06-01 21:33Z s5 demo
test showed the agent defaulting to `work` for in-place edit asks instead
of the press_key+type_text sequence, even after read_selection had fetched
the field content.

Changes:
- src/inline-tools.ts: type_text gets `mode: 'replace_all' | 'append' | 'at_caret'`.
  - 'replace_all' emits Cmd+A before paste/keystroke → field-wide rewrite is
    atomic from the caller's POV; no selection-state to manage.
  - 'append' is the existing `append: true` behavior, now named.
  - 'at_caret' is the previous default (paste at caret / honor selection per
    macOS Cmd+V semantics).
  - Legacy `append: true` still honored (coerces to mode='append') so no
    existing caller breaks.
- src/voice-agent.ts: new system-prompt line (between L716 and L717) carves
  out IN-PLACE EDITS: "call read_selection first, compute the edited
  version, then type_text to write it into the field." Prompt describes
  ROUTING INTENT in user-facing verbs ("edit", "write") — tool description
  carries the API ('mode: replace_all').

Why not at the prompt layer alone:
- Composing press_key+type_text was the existing workaround. It worked
  when the LLM thought to do it, but the default routing for "edit this
  paragraph" lands on `work` (correctly per L717 COMPLEX + L720 MISSING
  CONTEXT rules — but wrong for in-place edits where read_selection can
  resolve the field). The tool-level mode change makes the right path
  shorter (one tool call instead of two), so the LLM has less to compose.

Why not an AX-setValue tool:
- Considered. AXUIElementSetAttributeValue would bypass keystrokes
  entirely. Cleaner for native AppKit text fields, but inconsistent for
  web contenteditable (some browsers honor AX setValue, some only fire
  execCommand-paste flows). Defer until post-Observe-talk-2026.

Test:
- Type-check: clean.
- Schema validates all 3 modes + rejects bogus + accepts legacy append:true.
- Voice-agent restart loads new code + system prompt cleanly.
- Live s5-demo end-to-end test pending — requires voice session.
…onichi#1396)

The slide_control description claimed it "sends arrow keys to the frontmost
browser window" — but the implementation has been DOM manipulation via Chrome
execute javascript since sonichi#672's check-in (mutates `.slide.active` directly).

The misleading description steered Sutando AWAY from slide_control when the
deck's textarea was focused (live-edit demo on s5): assuming the keystroke
trap would catch arrow keys here too, the agent fell back to
press_key('leftarrow') — which IS arrow-key-based and got captured by the
focused textarea (cursor moves left within the field, deck doesn't move).
Hit during Observe-talk rehearsal 2026-06-01 23:32Z; transcript shows three
failed press_key attempts to navigate back to s5 before user gave up.

Update the description to:
- Accurately describe the DOM-manipulation backend
- Highlight focus-independence (works regardless of which element is focused)
- Explicitly recommend over press_key('leftarrow'/'rightarrow'/'space') for
  slide nav, with the failure mode named

Implementation unchanged. Description-only fix.
…inder skills (sonichi#1385)

* fix(quota-tracker): defer annotations so read-quota imports on Python 3.9

The burn-rate feature (sonichi#1319) added `BURN_HISTORY_FILE: Path | None` (module
level) and `-> dict | None` (return), both PEP 604 unions evaluated at
import/def time. On Python 3.9 — the system python3 on some hosts — that
raises `TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'`,
so `read-quota.py` crashes before printing anything. The proactive loop's
budget step (and the dashboard quota chip) silently lost quota data on those
hosts.

Add `from __future__ import annotations` to defer all annotation evaluation.
No runtime `get_type_hints`/typing use in the module, so lazy annotations are
behavior-preserving on 3.10+.

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

* fix(deal-finder): defer annotations so scan.py runs on Python 3.9

`scan.py` defines five PEP 604 union return annotations (`int | None`,
`str | None`) that are evaluated at def time. On Python 3.9 this raises:

    TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'

so the scan crashes on load before doing any work. Because the cron only
reports on a match, the crash was silent — the every-60-min deal scan has
been a no-op on any 3.9 host.

Add `from __future__ import annotations` to defer annotation evaluation
(PEP 563). No runtime get_type_hints/typing use, so behavior-preserving
on 3.10+.

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

* fix(deal-finder): use timezone.utc instead of datetime.UTC for py3.9

`dt.datetime.now(dt.UTC)` uses `datetime.UTC`, a 3.11+ alias. On Python 3.9
it raises `AttributeError: module 'datetime' has no attribute 'UTC'` —
surfaced once the annotation crash (prior commit) was cleared. Swap for
`dt.timezone.utc` (available since 3.2); identical result after the
following `.replace(tzinfo=None)`.

With both fixes the scan runs end-to-end on 3.9
("Done. 77 scanned, 0 matches, 0 newly seen.").

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…sonichi#1391)

macOS ships bash 3.2, which treats an empty array as unbound under `set -u`,
so `for dir in "${INCLUDE_DIRS[@]}"` aborted every non-flag gemini run with
'INCLUDE_DIRS[@]: unbound variable'. Guard the loop on ${#INCLUDE_DIRS[@]}.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…on parsers (sonichi#1402)

* fix(check-pending-questions): stop counting resolved items as pending

get_waiting_questions() splits on every `## ` heading across the whole
file, so entries kept below the `# Resolved` divider (the audit-trail
convention this file has used since mid-May) were miscounted as still
pending — the owner got re-notified (macOS + Discord DM + voice) about
already-answered questions on every cron fire.

Truncate the parsed content at the top-level `# Resolved` divider before
splitting. No-op when no such divider exists, so the free-form-delete
convention assumed by sonichi#1265 still works unchanged.

Verified against the live pending-questions.md: count drops 6 → 3
(the 3 resolved entries are no longer notified).

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

* fix(morning-briefing): stop speaking resolved items as pending

get_pending_questions() splits on every `## ` heading across the whole
file, so entries under the `# Resolved` divider were spoken as still
pending in the daily briefing ("6 pending questions" when only 3 are
open). Same divider-blindness as the check-pending-questions fix in
this PR — apply the identical truncation at `^#\s+Resolved\b`.

Verified against the live pending-questions.md: count drops 6 → 3.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…** markers (sonichi#1405)

get_pending_count() counted `**Status:** Waiting` / `**Status:** Answered`,
but questions are filed as free-form `## ` prose with no Status field (the
format sonichi#1265 documented as canonical) and moved below a `# Resolved` divider
once answered. So the dashboard "Pending" stat showed 0 even with open
questions — verified live: {open:0,done:0} when 3 are actually open.

Count `## ` sections per region instead (active = open, below `# Resolved`
= done). No-op when no divider exists.

Verified against the live pending-questions.md: 0/0 → 3/3.

Addresses the dashboard.py portion of sonichi#1404.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
… 5 Python services (sonichi#1398)

Addresses issue sonichi#1371 (Lucy's cross-bridge inconsistency scan). 13 bare except:
clauses across 5 hardlinked Python service files narrowed to except Exception: so
KeyboardInterrupt and SystemExit can propagate instead of being silently swallowed.

Also includes health-check.py ms-precision fix: task ID and file path now use
now_ms (already in scope) instead of int(time.time()), preventing filename collision
when two health-check --emit-task calls land within the same second.

Files:
- src/discord-bridge.py: 6 access.json file-read fallbacks (load_allowed, load_policy, etc.)
- src/agent-api.py: 3 JSON parse error handlers (/voice/state, /task-done, core-status)
- src/health-check.py: 1 pgrep subprocess fallback + ms-precision task ID fix
- src/dashboard.py: 2 date parse + git log fallbacks
- src/screen-capture-server.py: 1 unlink fallback during display enumeration

Stand: Echo Act IV Pro

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…chi#1400)

* fix(restart-voice-agent): clear stale pid file before kickstart

The voice-agent writes .voice-agent.pid on startup and exits if the file
already exists. When launchctl kills the LaunchAgent wrapper but leaves the
node worker alive (orphaned — observed 2026-06-02 after kickstart -k), the
new instance finds the stale pid file and immediately exits. The LaunchAgent
sees a clean exit (not a crash) and doesn't respawn, so kickstart appears to
succeed from the caller's view while audio stays broken.

Fix: read and kill the stale pid before kickstart, then remove the file so the
new instance can write its own pid cleanly.

Observed root cause: `kickstart -k` killed the npm/node wrapper (parent pid)
but left the tsx worker (child pid) alive as an orphan holding the pid file.

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

* fix(restart-voice-agent): argv-guard stale pid + tilde expansion

Two fixes per chi's review:
- Validate stale pid belongs to voice-agent.ts before killing (prevents
  accidentally killing unrelated processes that reused the pid)
- Expand leading ~ in WORKSPACE using ${HOME} so tilde-literal env values
  (e.g. SUTANDO_WORKSPACE=~/.sutando/workspace) don't break PID_FILE path

Stand: Echo Act IV Pro

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…persedes sonichi#1412/sonichi#1413) (sonichi#1434)

* fix(voice-agent): unified base-mode resolver (closes sonichi#1410, supersedes sonichi#1412/sonichi#1413)

Multi-substrate mode state (in-memory `meetingActive`, on-disk
`voice-mode.txt`, presenter HTTP server `:7877/presenter`, screen-companion
`activeMode`) was the root cause of gemini-3.1-flash-live-preview
fabricating `[System: Produce ZERO audio. Call NO tools.]` directives as
spoken output at session connect. Without an explicit base-mode
declaration, the model inferred meeting-mode silence from co-present-
flavored context.

PR sonichi#1412 added a per-activation `baseModePin` to screen-companion;
PR sonichi#1413 added a `CURRENT BASE MODE:` inline string to the system-prompt
instructions array. Both were partial — `baseModePin` only fires after
the model has already streamed for seconds, and the inline string
depends on ad-hoc branching kept in sync at every call site.

This PR extracts the mode logic into `src/voice-mode-resolver.ts` — a
pure, side-effect-free module that reads all substrates once and returns
a canonical `ModeState { mode, marker, isPresenter, isMeeting }`. The
`[BASE MODE: ...]` marker is injected at three points so the model sees
its mode explicitly before any other system text:

  1. Fresh-greeting (session-connect)
  2. Reconnect-greeting (resume)
  3. Instructions array (system_instruction, authoritative)

Priority: presenter > meeting > active. Screen-companion remains a
sub-mode that overlays the base (no change to skills/screen-companion/).

Unit tests cover: priority order, every-mode-emits-marker invariant
(regression against the empty-string return that `getPresenterStateMarker()`
defaulted to), and presenter-vs-meeting precedence when both substrate
states are set.

Stand: Echo Act IV (Mini)

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

* docs(voice-mode-resolver): clarify substrate read/write contract

Per Lucy's review on PR sonichi#1434: tighten the source-doc to make explicit
WHY the resolver reads 2 of the 4 substrates listed. The other 2 are
not independent state:

- voice-mode.txt is a write-only output mirror of meetingActive
  (read by web-client/Sutando.app/discord-voice-server; never read
  back by voice-agent itself).
- screen-companion `activeMode` is an orthogonal sub-mode that overlays
  the base; conflating the two axes would lose information.

No behavior change; comment-only.

Stand: Echo Act IV (Mini)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lent no-op (sonichi#1428)

* fix(slack): surface silent core fails via task timeout + remote watchdog DM

Two independent failures stacked in the 2026-06-02 outage: the core session
crossed the 1M extended-context threshold and looped on the "usage credits
required" API error (no turn ever completed), and the Slack path has no
failure feedback — so the user just saw silence mid-task.

1. slack-bridge: per-task timeout. pending_replies now records submitted_at;
   result_watcher posts a one-time "still working / may have hit a context or
   usage-credit limit" reply after SLACK_TASK_TIMEOUT_SEC (default 600s,
   mirrors task-bridge.ts). The entry is KEPT after notifying, so a late real
   result is still delivered. 0 disables.

2. health-check: --notify-slack DMs the owner directly via the Slack Web API
   (urllib, no slack_bolt dep). Crucially NOT gated on _any_core_alive():
   core_heartbeat.py runs as its own process, so a wedged Claude loop still
   "looks alive" and emit_task stays quiet — but core-proactive-loop flips to
   warn and this fires anyway. Token from $SLACK_BOT_TOKEN or $REPO/.env (so
   the minimal-env launchd job works without a secret in the plist); owner
   from access.json. Same per-set-hash 1h-cooldown dedup as the macOS surface,
   on a separate state file; dedup recorded only on successful send so a
   transient Slack outage doesn't suppress the alert. Benign on-demand warns
   (discord-voice/conversation-server) are filtered out to avoid DM spam.

   Wired into the launchd fallback plist so outages self-report remotely.

Tests: tests/health-check-notify-slack.test.py (filter/dedup/failure-retry),
tests/slack-bridge-task-timeout.test.py (notify-once/retain/disable).

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

* fix(core): self-heal the 1M usage-credit-gate wedge + fix watchdog silent no-op

Builds on the prior watchdog commit (surface silent fails via Slack timeout +
remote DM). That made the 2026-06-02 outage VISIBLE; this makes it impossible to
stay silent and lets the core self-heal — without disabling 1M context (most
users, esp. on subscriptions, have it; on Max/Team it's gateless).

Root cause (confirmed against Claude Code v2.1.161 docs): an unattended core in
1M extended context can hit the interactive `/usage-credits` gate, which CANNOT
be pre-authorized — no settings key, env var, or CLI flag exists; the enable is
per-session/account-side only. The core then loops on the API error while its
heartbeat process keeps ticking, so it "looks alive" and drains nothing.

1. health-check: FIX the watchdog's silent no-op. _slack_token_from_env_file
   read only $REPO/.env, but the bridge keeps SLACK_BOT_TOKEN in
   ~/.claude/channels/slack/.env (startup.sh sources exactly that). On a stock
   install creds resolved to None and the DM never fired — the watchdog looked
   installed but was mute. Now reads the channel .env first, then $REPO/.env.

2. health-check: NEW --recover-core. When the core is alive-but-wedged (oldest
   queued task older than RECOVER_WEDGE_SEC while the heartbeat ticks — the
   precise loop signature, and it catches the idle-wedge that
   check_core_proactive_loop misses since that only flags status="running"),
   auto-restart via scripts/start-cli.sh --restart — the one mechanism we own.
   A restarted session re-clears the gate by itself (the /usage-credits enable
   persists account-wide once set), and queued task files survive the restart,
   so 1M stays the default and no work is lost. Heavily guarded because
   restarting a 24/7 agent is consequential: sustained-wedge confirm window,
   30min cooldown, 3/hr give-up cap (then DMs "gave up"), never-from-inside-core
   (launchd's own process + start-cli.sh's own guard), and a DM before each
   restart so recovery is never itself silent. Clean no-op when healthy.

3. Graceful degradation, opt-in: start-cli.sh honors $SUTANDO_CORE_MODEL. The
   first restart of an episode keeps 1M; if the wedge recurs the next restart
   pins --model opus (standard 200K, no gate) so the agent keeps WORKING instead
   of re-wedging. Default unchanged — 1M is never disabled for everyone.

Wired --recover-core into the launchd fallback job (its own process, every 5min)
so the core self-reports AND self-heals off-machine.

Tests: tests/health-check-recover-core.test.py (10 cases: confirm window,
cooldown, escalation, give-up cap + dedup, failed-restart retry, just-booted and
dead-core guards). tests/health-check-notify-slack.test.py +case i (token read
prefers channel .env, falls back to $REPO/.env, empty when absent).

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

* fix(core): address @sonichi PR sonichi#1428 review — 3 blockers + lock + tests

- B1 (slack-bridge `_check_task_timeouts`): set `timed_out=True` only AFTER a
  successful `_send_reply`, not before. A single Slack hiccup previously left
  the flag True forever (`continue` skips it next pass) so the user never saw
  the timeout warning — recreating the silent no-op this watchdog fixes. Failed
  sends now retry on the next pass.

- B2 (recover_core_if_wedged): capture the wedge-restart DM result. Recovery
  still restarts when the DM fails (recovery > notification — don't leave the
  core wedged because Slack is down), but records `last_restart_dm_sent` in the
  state file and logs to stderr/launchd, so a restart is never invisible. Same
  record-only-on-success treatment for the give-up DM.

- B3 (recover_core_if_wedged): wedge detection no longer equates "old task" with
  "wedged". `_oldest_pending_task` now returns a (name|mtime identity, age); the
  recovery requires the SAME oldest task to persist across the confirm window —
  a draining backlog surfaces a different oldest each pass and resets — AND that
  core-status.json did NOT advance (`_core_status_ts`): a core making progress
  on a long single task isn't restarted out from under itself. Residual (a long
  task that never heartbeats status) is documented and bounded by cooldown +
  give-up.

- Suggestion: serialize the load→decide→restart→save critical section under an
  exclusive non-blocking flock on `<state_file>.lock`, so a manual --recover-core
  and the launchd job firing together can't both clear the cooldown and issue
  duplicate restarts. Second caller no-ops with {"action":"locked"}.

- Nit (test gaps): tests/health-check-recover-core.test.py +cases k (draining
  backlog never restarts), l (status-progress resets; frozen status still
  fires), m (held lock → locked), n (failed DM still restarts, records
  dm_sent=False). New tests/start-cli-model-pin.test.py (default→no --model;
  SUTANDO_CORE_MODEL=opus→`--model opus` before --remote-control).
  tests/slack-bridge-task-timeout.test.py +test_send_failure_retries (B1).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate (sonichi#1437)

* fix(schedule-crons): no inline-fire on registration + per-cron queue gate

Two bootstrap-storm mitigations bundled (M1 + M2 from the 2026-06-03
diagnostic on the 16:52Z 12-prompt avalanche).

**M1 — SKILL.md step 3 ambiguity.** The line "If prompt_skill is set,
invoke it as /skill-name" reads as "fire the skill immediately." When a
running session re-invoked /schedule-crons at 16:52Z, the prior session
inline-fired /morning-briefing + 5 cron-body prompts at one instant,
dropping 8 spurious prompts atop legit watcher TASK_FILE events. The
long-running session (9.5h old) drowned and ended at 16:54 without
processing two queued owner DMs.

Step 3 now explicitly says: pass `prompt: "/skill-name"` to CronCreate;
never invoke the skill or run the prompt body inline. Crons fire at
their scheduled cron expression, never on registration. Past-bug
reference embedded so future readers see the consequence.

**M2 — per-cron task-queue gate (defense in depth).** New helper
`scripts/cron-gate.sh <reason> <command...>` defers when any
`task-*.txt` is pending in `<workspace>/tasks/` (top-level only;
`archive/` and `processed/` subdirs don't count). Workspace resolves via
the M0 helper if present, else legacy `${SUTANDO_WORKSPACE:-...}` so
the script works on both main and the staging-workspace-revamp branch.

crons.example.json wraps every non-loop entry (morning-briefing,
daily-insight, pending-questions, sync-memory, obsidian-dream,
learned-skills-scan) with cron-gate. main-loop (/proactive-loop) is
exempt — it IS the owner-task handler; gating it would deadlock.

Tests: tests/cron-gate.test.sh — 7 cases (empty-queue runs / queued
defers / missing dir / archive+processed don't count / non-task files
don't count / usage error exits 2 / wrapped exit code propagates).
All 7 pass.

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

* fix(schedule-crons): unwrap daily crons from cron-gate; pin gate to sub-daily

Per Lucy's non-blocking review on sonichi#1437 (Discord #dev, 17:37:48Z): wrapping
the daily crons (morning-briefing 06:57, daily-insight 06:50, obsidian-dream
03:37, learned-skills-scan 07:30) means a single deferral skips the function
for 24h — a missed briefing day, missed dream pass, etc. — because there's no
near-term retry tick. For sub-daily crons (`*/30` pending-questions and
sync-memory), the next natural fire covers a skipped one within 30 min.

M1's no-inline-fire rule already kills the avalanche on registration. M2's
queue-gate is defense-in-depth and only earns its keep on high-frequency
crons where the avalanche-risk × queue-collision probability is meaningful.

Changes:
- `crons.example.json`: unwrap `cron-gate.sh` from the 4 daily entries
  (morning-briefing, daily-insight, obsidian-dream, learned-skills-scan).
  Keep wrap on the 2 `*/30` entries (sync-memory, pending-questions).
- `SKILL.md` "When to gate" decision rule: NEVER for main-loop, YES for
  sub-daily (`*/N`/hourly), NO for daily-or-less-frequent. Cites Lucy's
  catch so future cron additions know the rule.

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

---------

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

sonichi/sutando-skills is a private fleet-sync repo. New public community
repo sonichi/sutando-skills-community is the correct destination for
standalone skill contributions.

Stand: Echo Act IV Pro
Sync 203 upstream commits (2327fba..b0a29f0) into the fork. 60 conflicts
hand-resolved across src/, skills/, and tests/; full suite green
(TS 387/387, Python 86/86, tsc clean).

Key resolution decisions
- Path style: kept the fork's state-paths.js/state_paths.py + statePath()
  helpers in voice-agent, task-bridge, inline-tools; adopted upstream's
  status_read_path()/WORKSPACE_DIR in dashboard, agent-api, health-check
  (their clean code already used it; status_read_path falls back to the
  legacy root path so it's backward-compatible).
- conversation.log stays at <workspace>/ root (fork) — not logs/ — matching
  task-bridge's statePath('conversation.log') writer + import scripts.
- Conversation store: adopted upstream's per-surface schema (sonichi#1052) +
  recordToolCall real-time path; kept the fork's lazy node:sqlite require.
- discord-bridge / voice-agent / conversation-server / discord-voice-server:
  union of the fork's cloud-telemetry/billing + multi-provider (OpenAI)
  features with upstream's parse_markers refactor, discord-voice join
  trigger, observe-talk duck relay, config-file voice config, and bot/peer
  enforcement.
- Adopted upstream's task-file injection-safety format (task: last) +
  str()-normalized owner resolution + diagnose.py sonichi#1357 schema fix.
- workspace_default.resolve_workspace(): adopted upstream option-B (sonichi#1169) —
  no per-resolve auto-migrate; emits the legacy-state notice instead.
- Kept deleted: src/web-client.ts (folded into voice-agent, PR-A).
- Kept the fork's cross-node-sync skill (upstream removed it in sonichi#1108).

Flagged for follow-up
- Upstream's web-client.ts security scrubs (CodeQL #49/50/51) should be
  verified present in the folded voice-agent HTTP code.
- inline-tools.ts allowlisted in cwd-lint (repo-relative process.cwd() for
  skills/notes); memory-backup.py added to host-cli KNOWN_VIOLATIONS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rickchen007
rickchen007 merged commit 5fea4d3 into dev-client Jun 5, 2026
rickchen007 pushed a commit that referenced this pull request Jun 5, 2026
The upstream sync (#108) dropped the early .env/GEMINI_API_KEY pre-flight
block but kept its trailing consumer line, gluing `if [ $missing -eq 1 ]`
right after the stand-identity.json block where $missing is unset (its real
assignment is ~80 lines later). Every startup printed `[: -eq: unary
operator expected` to stderr; control flow survived only because it's an
if-condition under set -e. The fork's preflight (init.sh --preflight) is
intentionally non-blocking, so the late `missing` check is the only one
wanted. Delete the orphan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rickchen007 pushed a commit that referenced this pull request Jun 5, 2026
The upstream merge (#108) added yaml@^2.9.0 to package.json and the npm
lockfile but not pnpm-lock.yaml, so release.yml's `pnpm install
--frozen-lockfile` step failed (ERR_PNPM_OUTDATED_LOCKFILE) — a CI-only
path that build-app.sh doesn't exercise, so it passed local build but
broke the v0.4.0 release run. Regenerate the pnpm lockfile. Verified with
the exact CI command (pnpm install --frozen-lockfile → exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.