This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
Merge upstream/main (sonichi/sutando) into dev-client - #108
Merged
Merged
Conversation
#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>
This reverts commit 44ae982.
… 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>
…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>
…ook_up_reference (sonichi#819)" (sonichi#1359) (sonichi#1362) This reverts commit e956d1c.
… 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Syncs 203 upstream commits (
2327fba..b0a29f0) fromsonichi/sutandointo the fork. 60 conflicts hand-resolved acrosssrc/,skills/, andtests/.Verification (all green)
tsc --noEmit: clean208 files changed, +21,565 / −1,700.
Key resolution decisions
state-paths/statePath()helpers in voice-agent, task-bridge, inline-tools; adopted upstream'sstatus_read_path()/WORKSPACE_DIRin dashboard, agent-api, health-check (their clean code already used it;status_read_pathfalls back to the legacy root path, so it's backward-compatible).conversation.logstays at<workspace>/root, matching the fork's writer + import scripts.recordToolCall; kept the fork's lazynode:sqliterequire for the bundled app runtime.parse_markersrefactor, discord-voice join trigger, observe-talk duck relay, config-file voice config, and bot/peer enforcement.task:last),str()-normalized owner resolution, diagnose.py call-diagnostics: diagnose.py SELECT references non-existent sessions columns (tool_calls,events) sonichi/sutando#1357 schema fix, andresolve_workspaceoption-B (workspace_default: migrate-on-resolve is destructive-by-default — consider explicit migration trigger sonichi/sutando#1169, no per-resolve auto-migrate → legacy-state notice instead).src/web-client.ts(folded into voice-agent, PR-A).Three real merge artifacts caught by the test suite and fixed: an undefined
corePathvar (get_core_status), a duplicateVOICE_GOOGLE_SEARCH, and amode/appendparam mismatch intype_text.Follow-up (non-blocking)
inline-tools.tsallowlisted incwd-lint(repo-relativeprocess.cwd()for skills/notes);memory-backup.pyadded to host-cliKNOWN_VIOLATIONS— both pre-existing fork debt, documented via the tests' own escape hatches rather than changing behavior in a merge.🤖 Generated with Claude Code