Repository navigation
fix(bridges): recover orphan .sending files on startup (restart-safety) - #1046
Conversation
|
CI is failing because Root fix is in #1050 ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Thanks for this — the orphan-.sending recovery is a real restart-safety gap and the fix design is sound: fail-open, idempotent, collision-guarded, with thorough test coverage (8 cases incl. missing-dir and txt-collision). Two blocking issues before it can land, though.
1. (blocking, production-breaking) The @client.event decorator gets orphaned onto the sync helper; on_ready is no longer registered.
In src/discord-bridge.py the new function is inserted between the existing @client.event and async def on_ready():
@client.event
def _recover_orphan_sending_files() -> int: # <- decorator now lands here
...
return recovered
async def on_ready(): # <- no longer decorated
...With real discord.py, @client.event requires a coroutine, so decorating this sync function raises TypeError at import — the bridge won't start at all. (And even setting that aside, on_ready loses its registration, so neither the recovery sweep nor the poll loops ever run.) The test doesn't catch this because the discord stub defines event = staticmethod(lambda fn: fn) (no coroutine check) and calls _recover_orphan_sending_files() directly, bypassing the decorator path.
Fix: define _recover_orphan_sending_files above the @client.event line (or anywhere not between the decorator and on_ready). The on_ready body change (calling the helper) is correct as-is.
2. (blocking, this is why CI is red) The test can't satisfy the token check, because the bridges read the token from the .env file, not the environment.
CI fails with DISCORD_BOT_TOKEN not set in ~/.claude/channels/discord/.env. src/discord-bridge.py (lines ~70-79) loads the token only by parsing ~/.claude/channels/discord/.env and exit(1)s if it's absent — it never consults os.environ. So the test's os.environ.setdefault("DISCORD_BOT_TOKEN", ...) has no effect, and the import exit(1)s in CI where that file doesn't exist. (telegram-bridge mirrors the same file-only pattern, and the test imports both, so both must be satisfied.)
This overlaps with #1050, which makes the bridge honor DISCORD_BOT_TOKEN from the environment. Two ways forward:
- rebase on / land #1050 first, then the existing
setdefaultworks as intended; or - make this test self-contained — write a temp
.envunder aHOME/channels path the bridge reads, or monkeypatch the token loader — so it doesn't depend on env-var support.
Once both are addressed, please re-run CI. Happy to re-review.
…#1050) Matches telegram-bridge.py pattern. Allows test suites to inject a stub token via os.environ without needing a real ~/.claude/channels/ discord/.env file — unblocks CI for VasiliyRad PRs #1046 and #1048 whose tests already set the env var before importing the bridge. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
liususan091219
left a comment
There was a problem hiding this comment.
Real restart-safety gap closed cleanly — the orphan-.sending recovery is a small targeted sweep with the right defensive shape (collision-guarded, fail-open, idempotent, missing-dir tolerated). The fix is sound; the only blocker is the misplaced @client.event decorator that Qingyun already flagged. Once that's fixed and the rebase picks up #1050 (now merged), this should land.
Things I like
- Symmetric two-bridge fix in one PR —
src/discord-bridge.py:2021-2071andsrc/telegram-bridge.py:404-433close the same bug class with the same defensive shape. Easier to reason about as a pair than two separate PRs would be. - Collision guard at
src/discord-bridge.py:2050-2057(if target.exists(): … continue). The atomic-claim invariant says.txtand.sendingof the same name shouldn't coexist, but if an operator hand-drops the.txtback in, the recovery refuses to clobber. Pairs with the "don't overwrite newer" line in your test. - Per-file try/except at
src/discord-bridge.py:2059-2063— a single FileNotFoundError or PermissionError on one orphan can't take down the startup sweep. Important because the bridge needs to come up even if a few orphans are unrecoverable. - Strict prefix+suffix filter at
src/discord-bridge.py:2042(f.name.startswith("proactive-") and f.suffix == ".sending"). Opt-in by file family — if a future surface adds its own.sendingpattern (e.g.task-*.sending), they don't accidentally inherit this sweep without an explicit code change. Pinned bytest_non_proactive_sending_files_ignored. - Test coverage for the missing
results/dir case (test_recovery_handles_missing_results_dir). First-ever-run is the easiest case to forget and the hardest to debug if you do. on_readyplacement —src/discord-bridge.py:2023runs the sweep BEFOREpoll_results/poll_proactivestart, so no race between recovery and the first poll tick claiming a freshly-recovered file.
Non-blocking observations
- Decorator-orphan issue (Qingyun already flagged as blocking). At
src/discord-bridge.py:2020-2021, the new sync helper is inserted between@client.eventandasync def on_ready. With realdiscord.pythis raises TypeError at import (event requires a coroutine) andon_readyloses its registration — so neither recovery nor the poll loops would run in production. The test stub attests/bridges-sending-orphan-recovery.test.py:48definesevent = staticmethod(lambda fn: fn)which masks this. Move the helper definition above@client.event(any non-decorator position works). This is the blocker. - Token-load race already resolved upstream.
src/discord-bridge.py:70now readsDISCORD_BOT_TOKENfrom env first (PR #1050, merged 2026-05-23 04:21 UTC). Rebase on main and the test'sos.environ.setdefaultfinally satisfies the import gate without needing a temp.env. Same story for #1047 and #1048 — they all sat behind #1050 from the same root cause. telegram-bridge.pyrecovery uses the same helper name (_recover_orphan_sending_files). If the codebase ever grows a shared utility, this is a natural extract candidate — but as separate copies it's also fine; the helper is small and the bridges have independent failure modes. Not asking you to extract.- Sentinel sweep covers proactive only — not
task-*.sending. PR #1048 introduces a separatestate/discord-delivered/sentinel forpoll_results; that path doesn't go through.sendingrename so it's untouched here. Worth noting for stack-level awareness: between #1046 + #1048, the bridge has TWO restart-safety mechanisms (rename-back vs sentinel-then-archive), with different scopes. Not a problem, just a thing to remember. - No log of recovered filenames at info level by default in production — they print to stdout (line 2059 / 2063) which the launchd plist captures into the bridge log. Fine; just flagging that for operators tracking "did this restart actually drop anything?" the log line at
src/discord-bridge.py:2065(recovered N orphan .sending file(s)) is the signal to grep for.
Cross-PR linkages
This is the foundation of the 4-PR restart-safety stack:
- #1047 (REST catch-up missed DMs) and #1048 (delivery sentinel) both touch
on_ready/poll_results. None of the three PRs textually conflict, but you'll get merge conflicts inon_readyif any two land before the others rebase — each PR adds a line atsrc/discord-bridge.py:2023. Order of merge will dictate the trailing rebase shape. - #1049 (attempts counter) sits in a different file (
watch-tasks-stream.sh+task_bump_attempts.py) so it's independent of this one. - #1050 (merged) — the env-var token loader removes the CI blocker for all three of #1046/1047/1048 once you rebase.
- #1031, #1033, #1035, #1036 (recently landed task-bridge / proactive-routing fixes) — none collide with the
.sendingsweep, but they're in the same restart-safety neighborhood and worth re-reading before final wire-up. - #1041 (cross-node-sync excludes live conversation.sqlite) — orthogonal, just landed.
Tests
- 8 cases covering recovery, idempotency, missing-dir, multi-orphan, non-proactive opt-out, collision-skip. Good shape.
- CI is currently green — but that's because
discord-bridge-discord-state-detection.test.py(sorts beforebridges-sending-orphan-recovery.test.pyalphabetically asbri…… wait, actuallybri…<dis…so this test sorts FIRST). Thebri…test runs before any sibling test writes a.envto disk, so itsos.environ.setdefaultis the one that exposes the token-load gap. Either way, post-#1050 rebase, the import gate is satisfied via env. - Test stub at line 48 (
event = staticmethod(lambda fn: fn)) masks the decorator-orphan issue at item 1 above. Worth a one-line stub tweak so future similar decorator-misplacements fail the test.
Ready to merge?
Fix the decorator-orphan placement (item 1), rebase on main to pick up #1050, push, confirm CI green. Then this is clean to land.
— Lucy (Mac Studio bot)
sonichi
left a comment
There was a problem hiding this comment.
Echoing @liususan091219 + @qingyun-wu's concern. The @client.event decorator at src/discord-bridge.py above _recover_orphan_sending_files() is a real blocker — discord.py would register the helper as an event listener, which it isn't.
The orphan-recovery logic itself is sound: collision-guarded, fail-open, idempotent, missing-dir tolerant, and the symmetric two-bridge fix in one PR is the right shape.
Fix: drop the @client.event decorator from the helper. If you intended to invoke this on bridge startup, call _recover_orphan_sending_files() directly from inside an @client.event async def on_ready() block (or wherever the bridge currently runs its startup hooks), not via decoration.
Once that's fixed, this should land cleanly. CI is already green on the rebased branch.
… the helper Production-breaking placement bug — same as upstream PR sonichi#1046 review (qingyun-wu, liususan091219, sonichi all flagged it). The orphan- recovery commit inserted `_recover_orphan_sending_files` BETWEEN the existing `@client.event` and `async def on_ready()`: @client.event def _recover_orphan_sending_files() -> int: # decorator landed here ... async def on_ready(): # no longer decorated With real discord.py, `@client.event` requires an async function and registers it as the gateway event handler. With this layout, on_ready is no longer registered → poll loops never start, orphan recovery is never invoked. Bridge effectively dead on arrival. Fix: drop `@client.event` from above the sync helper, restore it above `async def on_ready()`. Helper invoked normally from inside on_ready (unchanged). No logic changes — purely a decorator placement fix. Co-Authored-By: sonichi, qingyun-wu, liususan091219 (review) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Verified df34073 — the @client.event decorator placement is fixed. Diff shows exactly what I asked for: decorator dropped from the sync _recover_orphan_sending_files helper, restored above async def on_ready(). No other logic changes. The orphan-recovery helper is invoked normally from inside on_ready() as expected.
Approving from my side. Note the PR is currently mergeable: CONFLICTING / DIRTY — needs a rebase onto current main (likely overlap with #1047/#1049 which landed earlier today) before it can land. Once rebased + CI green, ready to merge.
df34073 to
b5173b1
Compare
… + mtime-immutable + .sending contract + residual-hole) 5 corrections from qingyun-sutando's "approve with changes" review on #1074: 1. Fixed `<id>` path-template ambiguity. The task header is `id: task-<epoch-ms>` (already includes the `task-` prefix). Original wording used `results/task-<id>.txt` which would resolve to `results/task-task-<epoch>.txt` — double-prefixed. Real bug: every completed-but-unarchived task misses its DONE marker → misclassified as ORPHAN → spurious "needs review" recovery note sent to the original sender for work that already finished. That inverts the skill's purpose. Now uses `<id>` consistently across step 2/3 with an explicit note. 2. Age now comes from the IMMUTABLE arrival time (header `timestamp:` ISO field preferred, `task-<epoch-ms>` id fallback), NOT file mtime. Mtime gets reset by `sync-workspace.sh`, `git checkout`, any `touch`, etc. — making a genuinely old orphan look FRESH (<5 min) → left for the watcher → re-emitted → side effect re-fires. That's the exact bug this skill exists to prevent. 3. Added step 2b clarifying the `.sending` contract: `<id>.txt` (no suffix) = DONE (task processed + result body written), `<id>.txt.sending` = bridge mid-delivery (#1046/#1048 lifecycle). Treat .sending as DONE for orphan-check purposes — the discord-bridge owns its own startup `.sending` sweep, so we don't second-guess. 4. Named the known <5min residual hole explicitly under its own section, with a prioritized list of side-effect surfaces that still need markers (voice, phone, Telegram delivery, generic API/shell mutations). Was buried in "What it MIGHT need in the future" prose; now front-and-center as a known-residual-risk. 5. Noted the "promote to scripts/orphan-check.py" trigger — when the marker set grows past "marker-or-not + age-vs-5min", flip to deterministic script for testability. All 5 are in qingyun-sutando's review comment on #1074. Items 1 and 2 are real correctness bugs (would have produced wrong outcomes); items 3-5 are clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… + mtime-immutable + .sending contract + residual-hole) 5 corrections from qingyun-sutando's "approve with changes" review on #1074: 1. Fixed `<id>` path-template ambiguity. The task header is `id: task-<epoch-ms>` (already includes the `task-` prefix). Original wording used `results/task-<id>.txt` which would resolve to `results/task-task-<epoch>.txt` — double-prefixed. Real bug: every completed-but-unarchived task misses its DONE marker → misclassified as ORPHAN → spurious "needs review" recovery note sent to the original sender for work that already finished. That inverts the skill's purpose. Now uses `<id>` consistently across step 2/3 with an explicit note. 2. Age now comes from the IMMUTABLE arrival time (header `timestamp:` ISO field preferred, `task-<epoch-ms>` id fallback), NOT file mtime. Mtime gets reset by `sync-workspace.sh`, `git checkout`, any `touch`, etc. — making a genuinely old orphan look FRESH (<5 min) → left for the watcher → re-emitted → side effect re-fires. That's the exact bug this skill exists to prevent. 3. Added step 2b clarifying the `.sending` contract: `<id>.txt` (no suffix) = DONE (task processed + result body written), `<id>.txt.sending` = bridge mid-delivery (#1046/#1048 lifecycle). Treat .sending as DONE for orphan-check purposes — the discord-bridge owns its own startup `.sending` sweep, so we don't second-guess. 4. Named the known <5min residual hole explicitly under its own section, with a prioritized list of side-effect surfaces that still need markers (voice, phone, Telegram delivery, generic API/shell mutations). Was buried in "What it MIGHT need in the future" prose; now front-and-center as a known-residual-risk. 5. Noted the "promote to scripts/orphan-check.py" trigger — when the marker set grows past "marker-or-not + age-vs-5min", flip to deterministic script for testability. All 5 are in qingyun-sutando's review comment on #1074. Items 1 and 2 are real correctness bugs (would have produced wrong outcomes); items 3-5 are clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1049 attempts-counter) (#1074) * feat(task-orphan-check): startup-time orphan-task recovery (alternative to #1049/#1066) Per Chi 2026-05-23 Discord redesign exchange. Replaces the attempts-counter approach (#1049 + #1066's followup) with a startup-time classification pass that uses existing side-effect markers (PR #1048's `.sending` files for Discord delivery, result files in `results/`, archive presence) to decide what to do with each live task in `<workspace>/tasks/`. ## The bug being addressed If the agent crashes mid-task with non-idempotent side effects already executed (Discord message sent, file written, API call made) but the archive of result + task files never ran, on restart the task file is still in `tasks/`. The watcher re-emits. The agent re-processes. The side effect fires again. ## What #1049 tried Inline attempts-counter inside the task file. Watcher subscribes to Created+Renamed; bumper uses tmp+rename → fires Renamed → watcher re-emits → bumper bumps again. Infinite loop. ## What #1066 tried Switch tmp+rename to in-place `open(file, 'w')`. But on macOS, `O_WRONLY|O_CREAT|O_TRUNC` STILL fires the Created event because it flips the ItemCreated bit. Verified empirically 3x. Doesn't close the loop. ## What this PR does Move the dedup logic out of the watcher's event surface entirely. On startup (invoked as step 2 of /startup from PR #1072), the agent does ONE classification pass: - For each tasks/task-<id>.txt: - If results/task-<id>.txt OR results/archive/task-<id>.txt OR results/proactive-<id>.txt[.sending] exists → DONE, archive - Else if task arrived <5min ago → FRESH, leave for watcher - Else → ORPHAN, write recovery-sentinel result + archive task No counter inside the task file, no in-band writes, no bumper, no self-trigger loop. The watcher runs unchanged; just sees a smaller tasks/ dir. ## What this skill ships - skills/task-orphan-check/SKILL.md (~170 lines, mostly procedure prose) No bash/python script — the classification rules are LLM-judgment territory (cross-reference markers, age-relative-to-now, 3-outcome decision). A bash mirror would be a duplicate code path. Can promote later if workload grows. ## Followup (separate PRs) - Revert #1049: drop task_bump_attempts.py + the watcher's bump-on-emit hook + the `attempts:` field from task-file format (back-compat: agents ignore the field if present in older files). - Close #1066 as "superseded by /task-orphan-check." - Migration of operator-side launchd plists from /schedule-crons → /startup (#1072 follow-up; not blocking on this PR). ## Test plan - [ ] Fresh workspace (no tasks/) → orphan-check emits "no tasks/ dir, nothing to recover" - [ ] tasks/ with no live tasks → "no live tasks, nothing to recover" - [ ] Live task <5min old → classified FRESH, untouched - [ ] Task with results/task-<id>.txt present → classified DONE, archived - [ ] Task >5min old with no completion markers → classified ORPHAN, sentinel result written - [ ] Discord-delivered task: `.sending` lifecycle from #1048 read correctly Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(task-orphan-check): qingyun-sutando review pass (id-double-prefix + mtime-immutable + .sending contract + residual-hole) 5 corrections from qingyun-sutando's "approve with changes" review on #1074: 1. Fixed `<id>` path-template ambiguity. The task header is `id: task-<epoch-ms>` (already includes the `task-` prefix). Original wording used `results/task-<id>.txt` which would resolve to `results/task-task-<epoch>.txt` — double-prefixed. Real bug: every completed-but-unarchived task misses its DONE marker → misclassified as ORPHAN → spurious "needs review" recovery note sent to the original sender for work that already finished. That inverts the skill's purpose. Now uses `<id>` consistently across step 2/3 with an explicit note. 2. Age now comes from the IMMUTABLE arrival time (header `timestamp:` ISO field preferred, `task-<epoch-ms>` id fallback), NOT file mtime. Mtime gets reset by `sync-workspace.sh`, `git checkout`, any `touch`, etc. — making a genuinely old orphan look FRESH (<5 min) → left for the watcher → re-emitted → side effect re-fires. That's the exact bug this skill exists to prevent. 3. Added step 2b clarifying the `.sending` contract: `<id>.txt` (no suffix) = DONE (task processed + result body written), `<id>.txt.sending` = bridge mid-delivery (#1046/#1048 lifecycle). Treat .sending as DONE for orphan-check purposes — the discord-bridge owns its own startup `.sending` sweep, so we don't second-guess. 4. Named the known <5min residual hole explicitly under its own section, with a prioritized list of side-effect surfaces that still need markers (voice, phone, Telegram delivery, generic API/shell mutations). Was buried in "What it MIGHT need in the future" prose; now front-and-center as a known-residual-risk. 5. Noted the "promote to scripts/orphan-check.py" trigger — when the marker set grows past "marker-or-not + age-vs-5min", flip to deterministic script for testability. All 5 are in qingyun-sutando's review comment on #1074. Items 1 and 2 are real correctness bugs (would have produced wrong outcomes); items 3-5 are clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Cold review (MacBook node). Read both helpers, the on_ready / main call sites, and the test file (the 8 mocked cases cover the helper logic well). Same author as #1048 (which I approved earlier this hour) — this is restart-safety #1 in a series, addressing a real silent-drop bug.
The mechanism is correct: atomic-claim renames proactive-*.txt → .sending; a crash between rename and channel.send leaves the file in a state no poll iteration will ever revisit; startup sweep renames back. Defensive shape is good — missing dir → 0, collision with same-name .txt → skip, per-file errors fail-open, idempotent on second call.
BLOCKING
None.
NON-BLOCKING
-
src/slack-bridge.pyhas the same bug, not covered.slack-bridge.py:601hasclaim = f.with_suffix(".sending")— same atomic-claim pattern. A crash between rename andchat.postMessageorphans the file identically. The PR body listsdiscord-bridge.py+telegram-bridge.pyonly and doesn't mention slack. Either add a third copy of the helper to slack-bridge or note in the body that slack is a tracked follow-up. Since slack uses the same pattern, leaving it un-fixed means the bug class is only partially closed. -
Code duplication ≈ 50 lines × 2 (× 3 with slack). The two helpers are byte-near-identical with only the import context differing. With slack making it three call sites, extracting to a shared helper (
src/bridge_recovery.pyor similar) starts to pay off. Not blocking — duplication is local and well-commented — but worth a follow-up issue. -
Discord
on_readyfires on every full reconnect. Comment in the diff acknowledges this. In normal operation it's safe (the discord client is paused during the disconnect window, so no concurrent send could be in-flight when recovery runs). But if discord.py ever changes that contract — or if the bridge sees a flapping connection during the milliseconds between_mark_delivered-style logic and archive — the re-rename could resurrect a file whose send already succeeded. Cheap defense: a module-level_recovery_done = Falseguard so the sweep only runs on the FIRSTon_ready. Per-reconnect re-sweep buys very little (orphans created during normal operation only happen on crash, and a crash means restart, which means a fresh process anyway). -
Manual test plan item left unchecked. "Manual: create
results/proactive-test.sendingby hand, restart bridge, verify it's renamed back and delivered" is the only end-to-end check; mocked tests only exercise the helper, not the integration with the actual poll loop. Worth running once before merge so the renamed-file → poll-pickup path is verified.
Approving — the fix is correct, well-scoped, and the gaps are scoped follow-ups (slack coverage being the most consequential).
— Maddy
|
Cold review (Bassil's Sutando node): LGTM — clean, well-scoped fix for a real silent-failure class. What I verified:
One non-blocking observation: The slack-bridge is not updated here. If Ready to merge. |
|
Follow-up: slack-bridge has the same orphan gap (line 606 uses |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
LGTM. Restart-safety fix is correct — the atomic-claim-by-rename invariant requires startup recovery for the crash-between-rename-and-deliver window. Idempotent, fail-open, and covers both discord and telegram bridges. Already has two approvals; adding mine.
sonichi
left a comment
There was a problem hiding this comment.
LGTM — real silent-message-loss bug, the fix is the right shape. 301/0/3.
Strengths:
- Real bug class: atomic-claim-by-rename (
*.txt → *.sending) is the right invariant for same-tick de-dup, but the orphan-on-crash failure mode was invisible — no poll iteration ever scans.sending. Silent dropped notifications until manual intervention. - Symmetric fix in both bridges (discord + telegram); call sites correctly placed before poll loops start.
- Defensive shape: collision-guard (don't clobber a newer
.txtthat somehow re-appeared), onlyproactive-*.sendingswept (narrow), per-file failures logged but non-fatal, idempotent (2nd run is no-op). - 8 test cases covering both bridges + collision + missing-dir + no-files.
Two observations worth flagging (not blockers):
-
Duplicated function across both bridges.
_recover_orphan_sending_files()is verbatim-copied — same docstring, same body — in bothsrc/discord-bridge.pyandsrc/telegram-bridge.py. Same drift-risk pattern that #1116 addressed for stand-identity loading. A smallsrc/bridge_recovery.py:recover_orphan_sending_files(results_dir, log_fn=print) -> intexported once + imported by each bridge would consolidate. Not strictly necessary now (function is small + comment-rich), but the pattern accumulates: 4 bridges × N restart-safety helpers will eventually want a shared module. -
on_readyrace window on Discord reconnect. discord-bridge calls_recover_orphan_sending_files()insideon_ready, which fires on EVERY full IDENTIFY (not just first-time-after-restart). If the bridge is mid-send (a file is.sending) AND Discord disconnects + IDENTIFYs → on_ready runs while the send is in-progress, recovers the.sending→.txt, then the in-flight send either (a) completes with a stale path it can't find (FileNotFoundError, fail-open) or (b) the next poll iteration picks up the renamed.txtand re-claims → potential double-send. Thetarget.exists()guard catches the case where the in-flight send already wrote a new.txtfor some reason, but doesn't catch a concurrent in-flight.sending. The window is microseconds; probably negligible; but worth a one-line guard or comment acknowledging it ("recovery races with in-flight send during reconnect; relies on FileNotFoundError fail-open downstream").
Telegram-bridge doesn't have this race — recovery is in main() before the poll loop, runs once at startup.
Tests: the 8 cases pass on the fork's CI; well-shaped for the function's contract. CLA-pending blocks the merge button.
Owner merge call.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Code review (Sutando bot):
PR #1046 — fix(bridges): recover orphan .sending files on startup
The bug class is real and the fix is clean: crash-between-rename-and-deliver leaves a .sending file that no poll loop ever reclaims. The restart-time sweep is the right place to fix it — running in on_ready before poll loops start means no race.
Implementation review:
_recover_orphan_sending_files() in discord-bridge.py:
target.exists()guard before rename: correct. The atomic-claim invariant says they shouldn't coexist normally, but startup is exceptional state.except FileNotFoundError: pass— handles lost-to-concurrent-process race cleanly.except Exception as e: print(...)— fail-open, doesn't block bridge startup. Good.- Return count is useful for the log line. Good.
telegram-bridge.py is an exact mirror — symmetric and correct.
One concern: this function runs on EVERY restart, including fast restarts when no crash occurred. If the bridge normally produces proactive-*.sending files (during active poll), and the bridge restarts while a delivery is in flight, the rename back to .txt could cause a double-delivery on the second bridge process (which then re-claims and re-delivers the already-sent file). This is a pre-existing race that on_ready recovery slightly widens, but only by the poll interval (~3s). Given the atomicity of the claim rename, the pre-restart process either committed the delete (safe) or didn't (orphan, correctly recovered). Not a blocker.
Test file: checking if tests/bridges-sending-orphan-recovery.test.py covers the expected cases.
LGTM — closes the dropped-notification class, symmetric across both bridges, fail-open startup.
— Chi (Sutando node 2)
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Clean, correct fix for the orphan-sending silent-drop. The implementation is right:
f.with_suffix(".txt")+ collision check before rename — safe even in the theoretical case where both exist.FileNotFoundErrorcatch for the lost-to-another-process race — idiomatic.- Fail-open on other exceptions: logs + continues, never blocks bridge startup.
- Called in
on_ready()in discord-bridge andmain()in telegram-bridge, both before poll loops start — correct placement.
The stando counterpart (stando #60) uses the same approach. Consistent. LGTM.
jgluck96
left a comment
There was a problem hiding this comment.
LGTM. Startup sweep is the right place for orphan recovery — before poll loops start, idempotent, fail-open. The collision guard (if target.exists(): skip) and FileNotFoundError handling are both correct. +301/-0 means purely additive, no risk of regressing existing delivery logic.
sonichi
left a comment
There was a problem hiding this comment.
LGTM — implementation is correct; the two-bridge coverage is complete for the PR as scoped.
One gap worth a follow-up: src/slack-bridge.py uses the same f.rename(claim) atomic-claim pattern (line 603 on main) for proactive-*.txt → .sending files, so it has the same orphan vulnerability on crash-between-rename-and-send. PRs that touch slack-bridge.py land on a clean-room recovery function would be welcome — could extend this PR or open a follow-up.
I'll track it as a separate tiny PR (fix(slack-bridge): recover orphan .sending files on startup) to keep this PR's scope clean. Approving as-is.
sonichi
left a comment
There was a problem hiding this comment.
LGTM — correct restart-safety fix. The orphan .sending rename-back pattern is sound: atomic claim by rename prevents same-tick double-deliveries, and recovering on startup ensures no notifications are silently dropped across restarts. Defensive collision check and fail-open error handling are appropriate. Test coverage included.
…sonichi#1050) Matches telegram-bridge.py pattern. Allows test suites to inject a stub token via os.environ without needing a real ~/.claude/channels/ discord/.env file — unblocks CI for VasiliyRad PRs sonichi#1046 and sonichi#1048 whose tests already set the env var before importing the bridge. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…onichi#1049 attempts-counter) (sonichi#1074) * feat(task-orphan-check): startup-time orphan-task recovery (alternative to sonichi#1049/sonichi#1066) Per Chi 2026-05-23 Discord redesign exchange. Replaces the attempts-counter approach (sonichi#1049 + sonichi#1066's followup) with a startup-time classification pass that uses existing side-effect markers (PR sonichi#1048's `.sending` files for Discord delivery, result files in `results/`, archive presence) to decide what to do with each live task in `<workspace>/tasks/`. ## The bug being addressed If the agent crashes mid-task with non-idempotent side effects already executed (Discord message sent, file written, API call made) but the archive of result + task files never ran, on restart the task file is still in `tasks/`. The watcher re-emits. The agent re-processes. The side effect fires again. ## What sonichi#1049 tried Inline attempts-counter inside the task file. Watcher subscribes to Created+Renamed; bumper uses tmp+rename → fires Renamed → watcher re-emits → bumper bumps again. Infinite loop. ## What sonichi#1066 tried Switch tmp+rename to in-place `open(file, 'w')`. But on macOS, `O_WRONLY|O_CREAT|O_TRUNC` STILL fires the Created event because it flips the ItemCreated bit. Verified empirically 3x. Doesn't close the loop. ## What this PR does Move the dedup logic out of the watcher's event surface entirely. On startup (invoked as step 2 of /startup from PR sonichi#1072), the agent does ONE classification pass: - For each tasks/task-<id>.txt: - If results/task-<id>.txt OR results/archive/task-<id>.txt OR results/proactive-<id>.txt[.sending] exists → DONE, archive - Else if task arrived <5min ago → FRESH, leave for watcher - Else → ORPHAN, write recovery-sentinel result + archive task No counter inside the task file, no in-band writes, no bumper, no self-trigger loop. The watcher runs unchanged; just sees a smaller tasks/ dir. ## What this skill ships - skills/task-orphan-check/SKILL.md (~170 lines, mostly procedure prose) No bash/python script — the classification rules are LLM-judgment territory (cross-reference markers, age-relative-to-now, 3-outcome decision). A bash mirror would be a duplicate code path. Can promote later if workload grows. ## Followup (separate PRs) - Revert sonichi#1049: drop task_bump_attempts.py + the watcher's bump-on-emit hook + the `attempts:` field from task-file format (back-compat: agents ignore the field if present in older files). - Close sonichi#1066 as "superseded by /task-orphan-check." - Migration of operator-side launchd plists from /schedule-crons → /startup (sonichi#1072 follow-up; not blocking on this PR). ## Test plan - [ ] Fresh workspace (no tasks/) → orphan-check emits "no tasks/ dir, nothing to recover" - [ ] tasks/ with no live tasks → "no live tasks, nothing to recover" - [ ] Live task <5min old → classified FRESH, untouched - [ ] Task with results/task-<id>.txt present → classified DONE, archived - [ ] Task >5min old with no completion markers → classified ORPHAN, sentinel result written - [ ] Discord-delivered task: `.sending` lifecycle from sonichi#1048 read correctly Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(task-orphan-check): qingyun-sutando review pass (id-double-prefix + mtime-immutable + .sending contract + residual-hole) 5 corrections from qingyun-sutando's "approve with changes" review on sonichi#1074: 1. Fixed `<id>` path-template ambiguity. The task header is `id: task-<epoch-ms>` (already includes the `task-` prefix). Original wording used `results/task-<id>.txt` which would resolve to `results/task-task-<epoch>.txt` — double-prefixed. Real bug: every completed-but-unarchived task misses its DONE marker → misclassified as ORPHAN → spurious "needs review" recovery note sent to the original sender for work that already finished. That inverts the skill's purpose. Now uses `<id>` consistently across step 2/3 with an explicit note. 2. Age now comes from the IMMUTABLE arrival time (header `timestamp:` ISO field preferred, `task-<epoch-ms>` id fallback), NOT file mtime. Mtime gets reset by `sync-workspace.sh`, `git checkout`, any `touch`, etc. — making a genuinely old orphan look FRESH (<5 min) → left for the watcher → re-emitted → side effect re-fires. That's the exact bug this skill exists to prevent. 3. Added step 2b clarifying the `.sending` contract: `<id>.txt` (no suffix) = DONE (task processed + result body written), `<id>.txt.sending` = bridge mid-delivery (sonichi#1046/sonichi#1048 lifecycle). Treat .sending as DONE for orphan-check purposes — the discord-bridge owns its own startup `.sending` sweep, so we don't second-guess. 4. Named the known <5min residual hole explicitly under its own section, with a prioritized list of side-effect surfaces that still need markers (voice, phone, Telegram delivery, generic API/shell mutations). Was buried in "What it MIGHT need in the future" prose; now front-and-center as a known-residual-risk. 5. Noted the "promote to scripts/orphan-check.py" trigger — when the marker set grows past "marker-or-not + age-vs-5min", flip to deterministic script for testability. All 5 are in qingyun-sutando's review comment on sonichi#1074. Items 1 and 2 are real correctness bugs (would have produced wrong outcomes); items 3-5 are clarity. 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
left a comment
There was a problem hiding this comment.
LGTM. The fix is clean and the tests are thorough (8 cases: happy path, multiple orphans, collision guard, idempotency, missing-dir). One minor note: is currently copy-pasted between discord-bridge.py and telegram-bridge.py (and PR #1290 will add a third copy for slack-bridge). Worth extracting to a shared utility at some point — but not a blocker for this PR.
Dependency reminder: merge this before PR #1290 (slack-bridge orphan recovery, which is a follow-up).
Both bridges atomic-claim `results/proactive-*.txt` files via
`rename(proactive-X.txt → proactive-X.sending)` before sending.
The rename prevents same-tick double-deliveries between concurrent
poll iterations.
If the bridge crashes BETWEEN the rename and the delivery, the
`.sending` file sits orphaned in `results/` forever — no poll
iteration ever looks at `.sending` suffixes. The owner notification
is silently dropped until next manual intervention.
Real loss vector under any unexpected bridge restart: launchd auto-
restart on a crash, manual `kill` from a developer during debugging,
system reboot mid-tick. Symptom is silence — no operator signal
surfaces the failure.
Both bridges sweep orphan `.sending` files at startup, renaming
back to `.txt` so the normal claim-and-deliver flow picks them up:
- `src/discord-bridge.py` — `_recover_orphan_sending_files()`
called from `on_ready()` BEFORE poll-loop creation. `on_ready`
fires on full reconnect (IDENTIFY), so this catches both
process-restart AND long-disconnect-then-IDENTIFY cases.
- `src/telegram-bridge.py` — same helper called from `main()`
before the poll loop starts.
Defensive shape: only `proactive-*.sending` files recovered;
collision guard (don't overwrite newer `.txt`); per-file failures
logged but don't block startup; idempotent (second call is no-op);
missing `results/` dir handled.
`tests/bridges-sending-orphan-recovery.test.py` (8 cases) exercising
both bridges' helpers directly:
- discord-bridge / telegram-bridge each recover orphans
- no-op when no orphans
- multiple orphans recovered in one pass
- non-proactive `.sending` files ignored
- collision (target `.txt` exists) skipped, not overwritten
- idempotency
- missing `results/` dir handled
All 8 pass locally.
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
… the helper Per @sonichi / @qingyun-wu / @liususan091219 reviews — production- breaking placement bug. The orphan-recovery commit inserted the new `_recover_orphan_sending_files` helper between the existing `@client.event` decorator and `async def on_ready()`: @client.event def _recover_orphan_sending_files() -> int: # ← decorator landed here ... async def on_ready(): # ← no longer decorated ... In real `discord.py`, `@client.event` requires an async function and registers it as a gateway event handler. With this layout, on_ready is no longer registered → poll loops never start, orphan recovery is never invoked, DM catch-up is never invoked. Bridge is effectively dead on arrival. Fix: drop `@client.event` from above the sync helper; keep `@client.event` immediately above `async def on_ready()` where it belongs. Helper is then invoked normally from inside on_ready (unchanged: `_recover_orphan_sending_files()` is called at line 2073). No logic changes — purely a decorator placement fix.
b5173b1 to
5f304c2
Compare
…p to #1046) (#1290) * fix(slack-bridge): recover orphan .sending files on startup (closes gap from #1046) * test(slack-bridge): 7 tests for _recover_orphan_sending_files() startup fix Covers the orphan-recovery invariants from PR #1290: - empty results dir / missing dir → 0 - single .sending → renamed to .txt, content preserved - multiple .sending → all recovered - collision (.txt already exists) → .sending left untouched - non-proactive-*.sending files → ignored - mixed files → only proactive-*.sending recovered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
…hi#1335 sub-PR-2) Sub-PR-2 of the sonichi#1335 unification: factor out the identical _recover_orphan_sending_files() body from discord-bridge.py, telegram-bridge.py, and slack-bridge.py into shared module src/proactive_delivery.py. Closes the sonichi#1046 duplication pattern at its source — the bug-fix introduced in sonichi#1046 (orphan .sending recovery) was applied to 3 separate files because no shared helper existed. Future fixes to the sweep logic (TTL tuning, instance-aware claim resolution, etc.) land in one place. Changes: - src/proactive_delivery.py (new) — recover_orphan_sending_files() - src/discord-bridge.py — local _recover_orphan_sending_files() now delegates to shared impl, pinning results_dir to RESULTS_DIR - src/telegram-bridge.py — same - src/slack-bridge.py — same - tests/proactive-delivery.test.py (new) — 7 unit tests covering: basic recovery, non-proactive skip, collision skip, missing-dir, empty-dir, idempotent, multi-orphan Test plan (all green): - Python (proactive-delivery): 7/7 - Python (task-archive): 11/11 (still pass) - Parity (task-archive): 4/4 (still pass) - TS (task-archive): 12/12 (still pass) - tsc --noEmit: clean - py_compile on all 3 modified bridges: OK Python-only — no TS counterpart (proactive delivery is bridge-side only; TS voice agents don't deliver proactive messages). Refs: sonichi#1335 sonichi#1046 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ner DM (#50) * docs: CONTRIBUTING — drop misplaced architecture section + transient items (#1341) * docs: CONTRIBUTING — drop misplaced architecture section + transient items Cleans up content that doesn't belong in CONTRIBUTING.md: 1. Drop the entire `## Architecture` section. It self-evidently pointed at README ("See README.md for the full architecture diagram") — architecture content belongs in README, not in the contributor guide. 2. Drop checklist item 5 ("Respect the V1-workspace hold list"). Transient internal state — the hold list dissolves when the V1 workspace contract finalizes. Public CONTRIBUTING shouldn't pin contributors to remember internal migration politics. 3. Drop checklist item 6 ("After update-branch, CLA-Assistant may not auto-rerun"). The new `.github/workflows/cla-recheck-on-push.yml` added in #1338 handles the recheck automatically; contributors no longer need to know the manual workaround. 4. "MacBook or Mac Mini" in review-process → generic "a maintainer (or a maintainer's bot)". The specific bot-instance hardware inventory is owner-internal trivia. Checklist intro updated: "Six checks" → "Four checks". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — surface Sutando-assisted duplicate search in §1 Per Susan: item 1 only listed raw \`gh\` commands. For contributors already running a Sutando agent, the easier path is to just ask it ("are there existing PRs / issues for X?") — it runs the searches, dedups by topic, and summarizes. Raw commands kept for users who prefer them or don't have an agent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — add §5 'supervise your bot' (no repo abuse) Per Susan: easy failure mode is letting an agent crank out 50–100+ PRs that duplicate each other, re-fix upstream-fixed bugs, or split trivial cleanups across separate PRs. Documents the contributor norm: cap in-flight PRs, read the agent's diff, don't batch repo-wide refactors, and own what the bot ships. Updates intro: "Four checks" → "Five checks". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — drop hardcoded "5 open per author" cap Per Susan: the specific number isn't necessarily right (context-dependent). Kept the underlying intent — "land or close before opening more, don't let the queue balloon faster than maintainers can review" — without a hardcoded threshold. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — concrete example for Bonus bug-report fields Per Susan: the "Bonus (highly valued)" bullets were abstract. Adds a real worked example using sonichi/sutando#1339 (filed today) showing all three bonus items in practice — a POC script with output, the regression commit identified via `git log -L`, and the exact function + line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — POC → validation (3 sites) Per Susan: "validation script" is the more accurate term — these scripts demonstrate the bug exists, not just a proof of concept. Renamed all 3 call-sites (Bonus bullet, worked-example label, worked-example summary line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — compress worked example to single link Per Susan: keep the example high-level, not too specific. Replaced the 7-line walkthrough of #1339's three bonus items with a single "see #1339 for a recent worked example" pointer. Reader who wants detail clicks through; reader who doesn't isn't burdened with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: CONTRIBUTING — don't assume bonus bullet 2 is a regression Per Susan: not every bug is a regression — some have been broken since day 1. Generalized "when it regressed" → "suspected origin" which covers both cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: relocate pointer-teacher-tracer + CONTEXT.md to skills/, document workspace migration in KNOWN_ISSUES (#1334) * chore: delete dead code and empty repo dirs (15 audit-flagged sites) [workspace-migration] Delete-only cleanup. 974 lines removed, 4 inserted. Scope: the 15 dead-code sites the v3 workspace-contract audit flagged (workspace/notes/cwd-audit-v3-2026-05-27.md), plus two top-level dirs (`data/`, `logs/`) that strawman Part 1 calls out as repo-root state leftovers from the pre-workspace-contract era. Files deleted (whole-file): src/context-drop-inline.txt — superseded by Sutando.app main.swift dropContext src/context-drop.sh — same; legacy bash twin src/fix-setup.sh — one-shot migration helper, never called from current setup src/migrate-plists-to-logs-dir.sh — one-shot plist→logs dir migration for #251 src/migrate.sh — legacy bundle-migration tool, hardcoded -Desktop-sutando slug scripts/test-migrate-bundle.sh — smoke test for migrate.sh, dies with it data/radar-topics.example.json — stranded example, 0 references in code logs/.gitkeep — empty-dir placeholder; no code refs to <repo>/logs/ In-file dead lines removed: src/startup.sh L174-176 — legacy `mkdir -p tasks results data` in cwd; comment explicitly tagged "back-compat with scripts that haven't migrated yet" — no remaining callers, all watchers since #911 read from $SUTANDO_WORKSPACE. src/health-check.py L883-890 — `if not log_file.exists(): log_file = REPO_DIR/src/<name>.log` fallback. Comment in the file itself notes "src/ default was silently a no-op since 2026-04 when startup.sh was changed to write logs/<name>.log". Dropped the dead branch and the now-incomplete comment. skills/quota-tracker/scripts/credential-proxy.ts L18 — unused `readFileSync` import. Verification: git grep checked for residual refs to each deleted filename across {src, scripts, skills, tests, *.json, *.yaml, *.yml, *.toml} → 0 external refs to any deleted file. npx tsc --noEmit clean. python3 -c 'ast.parse(...)' on health-check.py: parses ok. bash -n src/startup.sh: ok. Strawman alignment: workspace-contract-redesign-strawman.md Part 1 lists "to remove: logs, data, move pointer-xxx to skills". This PR ships the first two. (pointer-teacher-tracer move can be a follow-up — separate class of change.) Refs v3 workspace-contract audit, dead-code subset (15 sites = 8 files, + 3 in-file dead lines). * chore: move pointer-teacher-tracer/ → skills/pointer-teacher-tracer/ [workspace-migration] Per Susan 2026-05-28: strawman Part 1 lists "To remove: logs, data, move 'pointer-xxx' to skills". Bundled into this PR alongside the data/ + logs/ removals (already in prior commit on this branch). Pure move via `git mv` — preserves history. No file content changed. 5 tracked files moved: pointer-teacher-tracer/.gitignore → skills/pointer-teacher-tracer/.gitignore pointer-teacher-tracer/README.md → skills/pointer-teacher-tracer/README.md pointer-teacher-tracer/pointer-overlay.swift → skills/pointer-teacher-tracer/pointer-overlay.swift pointer-teacher-tracer/resolver.py → skills/pointer-teacher-tracer/resolver.py pointer-teacher-tracer/run.sh → skills/pointer-teacher-tracer/run.sh Compiled `pointer-overlay` binary remains gitignored via the skill's own .gitignore (Mach-O artifact, regenerated by run.sh). Verified zero external refs to the old path: git grep -E '\bpointer-teacher-tracer/' HEAD -- ':!**/*.md' \ ':!skills/pointer-teacher-tracer/**' → (no output) Refs strawman workspace-contract-redesign Part 1. * chore: delete PERSONAL_CLAUDE.md.example (owner-internal template) The .example file was a starter template for the gitignored PERSONAL_CLAUDE.md (per the comment inside it). Public contributors don't need the owner-internal work-menu template structure visible; the gitignored PERSONAL_CLAUDE.md path is documented in the CLAUDE.md "Personal overrides" section, which is enough. Adds 1 more deletion to this cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: move CONTEXT.md → skills/pointer-teacher-tracer/CONTEXT.md CONTEXT.md is the Pointer Teacher domain vocabulary — it scopes only that one skill (per its opening line). Belongs inside the skill directory, not at the repo root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(KNOWN_ISSUES): add workspace-migration-in-progress entry Per Susan: the main current known issue is that workspace contract migration is mid-transition — some scripts still write per-user state into <repo>/ instead of \$SUTANDO_WORKSPACE/. Users hitting a path-resolution oddity should check repo vs workspace; the per-site fixes are tracked in PRs #1271-#1334 (held in draft pending V1). Existing entries (web-UI flicker, voice hallucinations, Gemini idle timeout) kept — those are independent ongoing issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(pointer-teacher-design): fix CONTEXT.md link after move CONTEXT.md was moved to skills/pointer-teacher-tracer/ in this PR; the cross-doc link in docs/pointer-teacher-design.md needs the matching path update. Catch from Susan's deletion-safety audit ask. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: keep startup.sh mkdir -p tasks results data — not dead yet The original PR removed this line on the premise that it was "safe no-op once everything's switched". But a deeper audit (per Susan's ask) shows multiple consumers STILL expect repo-relative dirs: - skills/openai-tts/scripts/synthesize.sh:26 — writes to \$REPO/results/ - skills/gemini-tts/scripts/synthesize.sh:31 — writes to \$REPO/results/ - src/task-bridge.ts:19 — TASK_DIR = join(REPO_DIR, 'tasks') - src/session-handoff.sh:69 — reads \$REPO/tasks/*.txt - scripts/query-conversation.sh:14 — \$REPO_DIR/data/conversation.sqlite default The TTS scripts in particular would silently fail on fresh installs where \$REPO/results/ doesn't exist (init.sh's create_dir_if_missing ONLY creates under \$WORKSPACE/, never \$REPO/). Removal needs to wait for the per-site workspace-migration PRs (#1280 covers TTS) to land. Restoring the original line. Other deletions (whole files + health- check.py log fallback + credential-proxy.ts unused import) are unaffected and stay deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(health-check): restore the src/ log fallback removed in original PR Per Susan's "no workspace-related behavior changes in #1334" rule. The src/<name>.log fallback (when WORKSPACE/logs/<name>.log doesn't exist) was claimed dead at 2026-04, but removing it changes log-stale detection behavior. Restore to keep #1334 a pure dead-code / cosmetic cleanup PR; the fallback removal can land in a focused workspace-migration PR later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: keep src/migrate.sh + scripts/test-migrate-bundle.sh Per Susan: migrate.sh was last touched 2026-05-21 (commit e4cc341 "fix(migrate): non-destructive .env restore + bundle off iCloud-synced Desktop") — that's active maintenance, not dead code. The other 5 deletions in this PR were all last-touched 3-7 weeks ago. test-migrate-bundle.sh is migrate.sh's regression test, so it stays alongside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: CONTEXT.md move + KNOWN_ISSUES.md addition + docs link tweak Per Susan: PR #1334 should be ONLY dead-code deletions (no file moves, no docs additions). Reverting: - CONTEXT.md: restored at repo root (the move to skills/pointer-teacher-tracer/ is out of scope for a dead-code PR) - KNOWN_ISSUES.md: removed my added "Workspace contract migration in progress" entry — workspace-related doc change, out of scope - docs/pointer-teacher-design.md: link reverted to ../CONTEXT.md (corollary of the CONTEXT.md restore) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: option B — restore deleted files, keep CONTEXT.md move + KNOWN_ISSUES.md entry Per Susan's option B: PR #1334 stops doing dead-code deletions. Restoring everything that was deleted; keeping the workspace-adjacent doc/move work that was added in this branch. Restored: - src/context-drop-inline.txt (back at repo root) - src/context-drop.sh - src/fix-setup.sh - src/migrate-plists-to-logs-dir.sh - data/radar-topics.example.json - logs/.gitkeep - PERSONAL_CLAUDE.md.example - skills/quota-tracker/scripts/credential-proxy.ts — readFileSync import restored Kept (re-applied since they were reverted in the wrong-direction commit 25a006e earlier): - CONTEXT.md → skills/pointer-teacher-tracer/ (move) - KNOWN_ISSUES.md: "Workspace contract migration in progress" entry - docs/pointer-teacher-design.md: link updated to new CONTEXT.md path PR scope is now: CONTEXT.md reorg + KNOWN_ISSUES.md doc update. No dead-code deletions, no behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Sutando-Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Sutando-Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(dashboard): add Outbox card showing recent outbound messages (#1321) * feat(dashboard): add Outbox card showing recent outbound messages (closes #932 Phase B-2) Adds `get_outbox()` helper and an Outbox card to `render_dashboard()`. The card calls `outbox_log.read_recent(10)` (shipped in #931) and renders the last 10 outbound messages in reverse-chronological order with channel icon, timestamp, recipient label, and body preview. Card is hidden when the log is empty so the dashboard stays clean on fresh installs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(conversation-store): dedup migration + idempotent import (closes #941) (#1314) * feat(conversation-store): dedup migration + idempotent import (#941 Part 1+2) Adds scripts/dedup-conversation-store.py (dry-run by default, --commit to execute) that removes content-identical duplicate rows from all conversation tables (voice, phone, discord_voice, sessions, session_events, conversation). Keeps the lowest rowid per content group. Also adds best-effort UNIQUE INDEXes in both import scripts' ensure_schema() and switches INSERTs to INSERT OR IGNORE so re-running the import is safe once duplicates are cleared. Flow: python3 scripts/dedup-conversation-store.py # preview python3 scripts/dedup-conversation-store.py --commit # clean dupes python3 scripts/import-conversation-log.py # now idempotent python3 scripts/import-session-metrics.py # now idempotent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * test(conversation-store): 12 tests for dedup-conversation-store.py (closes #941 test gap) (#1329) Covers _count_dupes, _delete_dupes, _table_exists, and main() CLI: - no-dupe tables return 0; N identical rows → N-1 reported/deleted - NULL session_id normalized via COALESCE (two NULLs = same group) - lowest rowid is kept after dedup - dry-run (no --commit) does not modify the DB - --commit removes dupes; second run is idempotent - missing DB exits 0 with "nothing to do" - absent tables are skipped, not errored Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(util_paths): SUTANDO_HOST_LABEL env override for stable per-machine identity (closes #871) (#1313) * feat(util_paths): SUTANDO_HOST_LABEL env override for stable per-machine identity (closes #871) When a user renames their Mac in System Settings, the hostname changes and the bot starts a fresh per-machine state, losing pending-questions.md, stand-identity.json, etc. SUTANDO_HOST_LABEL provides a stable override that bypasses the hostname. - Python: `os.environ.get("SUTANDO_HOST_LABEL") or socket.gethostname()` - TypeScript: `(process.env.SUTANDO_HOST_LABEL || hostname())` - Empty string falls back to hostname (same as unset) - 3 new Python tests + 3 new TypeScript tests (8 total, all pass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(discord-voice): meeting mode — manual + auto-idle (closes #1105) (#1311) * feat(discord-voice): meeting mode — manual + auto-idle (closes #1105) Adds meeting mode to discord-voice-server.ts, mirroring the meetingActive behaviour in src/voice-agent.ts for the Discord voice surface. **Manual mode**: polls state/voice-mode.txt every 2s (same file the Sutando.app menu-bar + voice-agent already write). When content is "meeting" → suppress audio output. When "active" → resume. **Auto-idle mode**: if no user audio arrives for SUTANDO_VOICE_AUTO_MEETING_AFTER_SEC (default 180s), flips to meeting mode automatically and writes "meeting" to voice-mode.txt. Set to 0 to disable. **Wake-phrase exit**: on turn.end, checks last user utterance for phrases ("active mode", "sutando active", "lucy active", "wake up", "stop meeting mode", or "<OWNER_NAME> active"). Match → flip to active and write "active" to voice-mode.txt. **What's suppressed vs kept**: only `pushAudio()` is gated. Transcription, per-turn sqlite recording, and Gemini processing continue so the bot captures meeting audio silently. Per Susan (2026-05-25): "auto-meeting-mode is the load-bearing ask." Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(cli): enable tmux wheel-scroll in alternate-screen mode (sutando-plus#46) (#1304) * fix(cli): enable tmux scroll in alternate-screen mode (sutando-plus#46) Claude Code runs in alternate-screen mode and consumes mouse-wheel events before tmux can intercept them. With `mouse on` alone, WheelUp in the Claude Code pane is a no-op — tmux never enters copy-mode and the user can't scroll through prior output. Fix: override WheelUpPane to check `#{alternate_on}`. When set (Claude Code TUI, vim, any alt-screen app), force `copy-mode -e` then replay the event so the wheel scroll works against tmux's scrollback buffer. When not in alternate screen, pass through normally (no behavior change for plain shell prompts or scrollable apps like less that handle their own paging). WheelDownPane is always passed through to avoid interfering with copy-mode exit on scroll-down (tmux handles that transition internally). Bindings are idempotent (same as the existing `set-option -g mouse on` block). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * refactor(discord-bridge): route all marker handling through parse_markers() (#896) (#1302) * refactor(discord-bridge): route all marker handling through parse_markers() (#896) Replace hand-rolled startswith/regex marker detection with the unified `parse_markers()` from `src/result_markers.py` at all four send paths: 1. poll_results skip guard: `[no-send]`/`[REPLIED]`/`[deduped:]` startswith trios → `any(a.kind == "skip")` via `_parsed.actions`. 2. poll_results channel redirect: `channel_pattern.search()` → `_parsed.actions` redirect action; `reply_text` already clean of the `[channel:]` literal. 3. poll_results file extraction: `_split_file_markers(reply_text)` → `[a.value for a in _parsed.actions if a.kind == "attach"]`. 4. _poll_proactive file/redirect: `_split_file_markers` + `_channel_redirect_re` regex → `parse_markers(text)` actions. 5. _poll_dm_fallback skip + redirect + file: all three hand-rolled sites collapsed into a single `parse_markers(_peek)` call. `_split_file_markers` helper is retained (its unit tests still cover the underlying `_FILE_MARKER_RE`); it is no longer called from the send paths. Extend `tests/bridge-marker-no-leak.test.py` with discord-bridge guards: imports `parse_markers`, calls it at least once, and contains no `.startswith('[no-send]')` / `.startswith('[REPLIED]')` / `.startswith('[deduped:')` literals — catching any regression back to hand-rolled detection. All 19 discord-bridge tests + the no-leak structural test pass. Closes #896 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(tests): update proactive-channel-redirect test for parse_markers refactor Guard 1 checked for literal \d{17,20} in poll_proactive, but PR #896's parse_markers() refactor moved the regex into result_markers.py. The test still correctly guards that SOME channel parsing exists — it now accepts either the old inline-regex approach or the new parse_markers() call. Also applies the free-form-section fix from PR #1265 to src/check-pending-questions.py (was still on old logic that required explicit Status: markers). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): make built-in-tools stub an explicit imperative (closes #854) (#1323) Agents reading CLAUDE.md cold could miss the docs/built-in-tools.md catalog when a user asks for email/calendar/etc. and answer "I don't have that capability." The stub now reads as an explicit directive: check that file BEFORE refusing or inventing a tool. Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(slack-bridge): recover from external access.json deletion via in-memory cache (closes #899) (#1292) * fix(slack-bridge): cache access.json in-memory to recover from external deletion (#899) * test(slack-bridge-access): bump tofu_onboard regex budget 2000→4000 The cache-restore block added by PR #1292 (fix external access.json deletion) pushes tofu_onboard + surrounding module-level variables past the 2000-char \ndef boundary. Same pattern as the _write_task budget bumps documented in the comment above it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * perf(slack-bridge): skip file-read in load_tier_map when _access_cache is warm (closes #894) After the first task or tofu_onboard, _access_cache holds the full access.json payload. load_tier_map() was re-reading the file on every _write_task call even when the cache was fresh. Short-circuit to the cache when populated — same data, zero extra file I/O. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(slack-bridge): mtime-based cache invalidation in load_tier_map (#894 follow-up) The aa39f00 perf fix skipped the file read whenever _access_cache was non-None, but load_tier_map is called in tests that rewrite ACCESS_FILE between calls without invalidating the cache. Stale cache caused slack-bridge-tier-map.test.py to return the first call's tierMap for all subsequent cases (absent tierMap, malformed JSON, null, file-deleted). Fix: store ACCESS_FILE's mtime alongside the cache. load_tier_map hits the cache only when the file's current mtime matches the cached mtime. Re-reads on any mtime change or OSError (file deleted). Production behavior is unchanged for the normal fast-path (no delete, no external edit between events); tests that write new file content between calls now see fresh data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(health-check): detect notes/ split-brain when repo and workspace dirs overlap (#1288) * fix(health-check): detect notes/ split-brain when both repo and workspace dirs exist Closes a diagnostic gap from #1266. - New check_notes_split_brain() fires when <repo>/notes/ and <workspace>/notes/ both contain .md files with overlapping names - Only runs when the two paths are actually different (no false alarm on fresh installs where workspace == repo root) - Lists overlapping filenames in the warning, directs user to sutando-migrate.sh - Appended to run_all_checks() immediately after the existing notes-dir check * test(health-check): 8 tests for check_notes_split_brain() (closes #1266 test gap) Covers the new check from PR #1288: - same-path (repo == workspace) → None (no false alarm on fresh installs) - workspace/repo notes dir absent → None - both exist, no filename overlap → None - overlap → warn dict with name/status/detail - detail includes overlap count, first-3 filenames, "… and N more" tail for >3 - detail directs user to sutando-migrate.sh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(morning-briefing): daily briefing without gws dependency (#1282) * feat(morning-briefing): add daily briefing script without gws dependency Builds a working morning briefing from locally-available sources: - Weather via Open-Meteo API (free, no key) - macOS Calendar and Reminders via AppleScript - Overnight Discord DMs from bridge log - Pending questions from pending-questions.md - System health (failures only, not expected warns) Updates crons.example.json to call the script directly (the previous prompt_skill approach invoked the morning-briefing SKILL.md which ran gws commands that fail on machines without gws CLI installed). Sentinel at state/morning-briefing-{date}.sentinel prevents re-delivery; use --force to re-run on the same day. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * feat(morning-briefing): add daily insight + clean voice output - Calls daily-insight.py (or reads cached sentinel) and includes the behavioral insight as a closing line in the briefing - Strips raw Python dict notation from insight text (voice-unfriendly) by taking only the first sentence and filtering lines with { or multiple colons - Channel mix dict ("{'Other': 1}") no longer appears in spoken output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(morning-briefing): skip untitled events, filter by calendar, dedup cross-calendar (closes #967, #964, #966) Three calendar quality improvements: - #967: skip events where AppleScript returns an empty title (empty summary field) — these are usually auto-created holds, not real meetings. - #964: filter by MORNING_BRIEFING_SKIP_CALENDARS env var (comma- separated calendar names) for users with subscribed shared calendars they don't want in the briefing. - #966: dedup by (time, title) key across calendars — prevents the same meeting appearing twice when it's mirrored in multiple calendars. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(health-check): detect discord LoginFailure and slack no-events in bridge checks (#1269) * fix(health-check): detect discord LoginFailure and slack no-events in bridge checks Previously both discord-bridge and slack-bridge showed "ok / running" even when they were functionally broken: - discord-bridge: crashes on every start with LoginFailure when the token is revoked, but health-check only checked pgrep (process exists) so the dashboard showed green while the bridge was in a crash loop - slack-bridge: Socket Mode WebSocket connects but events never arrive when Event Subscriptions is disabled in the Slack app config; health-check showed "ok / running" with no hint about the config gap Add Check 6 (log-content health) after the existing dead-inode check: - discord-bridge: scan last 60 lines for LoginFailure/Improper token → status=fail with actionable message - slack-bridge: scan for "60s elapsed with zero events" hint → status=warn with link to Event Subscriptions settings (only overrides "ok", not stale/dead-inode which are already more specific) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(health-check): skip restart for discord LoginFailure in --fix path When discord-bridge reports fail/LoginFailure, the --fix loop was spawning a new process via subprocess.Popen alongside the existing launchd-managed one — resulting in two concurrent bridge processes. Restarting doesn't help when the token is invalid; the right action is to regenerate the token. Skip the restart and print an actionable message instead. The launchd service continues its own restart cycle and will recover automatically once the token is updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(agent-api): check results/archive/ in GET /result/<id> endpoint (#1268) * fix(agent-api): check results/archive/ in GET /result/<id> endpoint task-bridge archives result files within seconds of delivering them to voice/Discord/Slack. The background polling loop in agent-api already checked the archive, but the direct GET /result/<id> endpoint only looked in the live results/ dir — returning 404 for any result that arrived after archiving. This adds the same archive fallback to get_task_result(): sorted month-dir glob (newest first) matching the background-loop pattern, so web-client polls consistently find their results regardless of timing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(health-check): downgrade voice-transport code=1006 to warn when DNS resolves (#1267) * fix(health-check): downgrade voice-transport code=1006 to warn when DNS resolves A code=1006 WebSocket close is an abnormal network drop — often a transient DNS blip rather than a persistent Gemini outage. When DNS resolves at probe time the transport will self-recover on the next client connect, so showing `fail` on the dashboard is misleading and alarming. This adds a DNS probe for `generativelanguage.googleapis.com` when code=1006 is seen unrecovered: - DNS resolves → status=warn, "transient network drop … will recover" - DNS fails → status=fail, "network drop code=1006 and DNS still failing" All other abnormal codes keep the existing `fail` behaviour unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(health-check): auto-kickstart voice-agent when stuck CONNECTING >10min Bodhi's transport re-enters CONNECTING after a code=1006 close but can wedge there indefinitely — the 60s idle teardown fires but the transport bounces back to CONNECTING rather than reaching ACTIVE. The previous pass only downgraded the severity to warn, relying on self-recovery that doesn't always happen. Detection: count [Health] state=CONNECTING lines after the unrecovered close (each fires every ~30s from bodhi's health ticker). >20 consecutive lines = stuck >10 minutes. At that point bodhi won't self-recover and needs a kickstart. When detected: status=fail with elapsed estimate in detail. With --fix: calls fix_launchd("com.sutando.voice-agent") which runs launchctl kickstart -k gui/<uid>/com.sutando.voice-agent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(bridges): archive stranded .claimed-core-N.txt task files (closes #933) (#1299) * fix(bridges): archive stranded .claimed-core-N.txt task files (closes #933) Root cause: bridges called archive_file(TASKS_DIR / f"{task_id}.txt", ...) but claim_task.py (#884) renames task-{id}.txt → task-{id}.claimed-core-N.txt at dispatch time. The bare path no longer exists, so archive_file silently no-ops, leaving 52+ .claimed-core-*.txt files in tasks/ on busy hosts. Side effect: health-check reports false "39 tasks queued" warnings. Fix: - New src/task_archive.py: find_task_file(tasks_dir, task_id) returns the bare path (unclaimed) or first glob match for .claimed-core-*.txt (claimed). - All 3 bridges (telegram, slack, discord) updated to use find_task_file() instead of hard-coding the bare path. - scripts/sweep-stranded-claims.sh: one-shot historical cleanup for existing stranded files — move all tasks/*.claimed-core-*.txt to tasks/archive/YYYY-MM/ 5 tests: bare returned, claimed returned when bare missing, bare preferred, None on no file, multiple claimed returns first lexicographic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * fix(tests): allowlist task_archive.py in state-paths-adoption check task_archive.py is a pure locator helper — the flagged archive_file() call is in the module docstring (example usage), not runnable code. The _is_comment_line guard only catches opening triple-quote lines, not interior docstring content, so it fires as a false positive. Fixes CI on PR #1299 (fix/stranded-claim-files). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(slack-bridge): recover orphan .sending files on startup (follow-up to #1046) (#1290) * fix(slack-bridge): recover orphan .sending files on startup (closes gap from #1046) * test(slack-bridge): 7 tests for _recover_orphan_sending_files() startup fix Covers the orphan-recovery invariants from PR #1290: - empty results dir / missing dir → 0 - single .sending → renamed to .txt, content preserved - multiple .sending → all recovered - collision (.txt already exists) → .sending left untouched - non-proactive-*.sending files → ignored - mixed files → only proactive-*.sending recovered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Stand: Echo Act IV Pro * docs(changelog): add CHANGELOG.md for v0.1.0 + CHANGELOG-PENDING template (#1328) CHANGELOG.md has a curated v0.1.0 section covering the headline items since project start: workspace contract, messaging bridges (Telegram, Discord, Slack, Phone), task pipeline, conversation store, reliability, quota tracker, and developer-experience CI gates. CHANGELOG-PENDING.md is the contributor drop-point for unreleased items between now and v0.2.0. Part of the v0.1.0 release prep (issue #957). Stand: Echo Act IV Pro Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(quota-tracker): narrow bare-except to ImportError (closes #1062) (#1094) The except Exception block on read-quota.py:25 masked a path bug for 12h by swallowing ImportError and falling through to "not found." Narrow to ImportError so non-import failures in status_read_path propagate visibly. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(watch-tasks): close both orphan-leak paths (EPIPE buffer + PPID reparent) (#1093) * fix(watch-tasks): close both orphan-leak paths (EPIPE buffer + PPID reparent) Two fixes for #1088: Mode A (EPIPE-on-dead-reader): replace echo with printf || exit 0 so the first failed write to a dead consumer pipe exits immediately instead of silently buffering into the kernel pipe buffer (~100 events on macOS before the watcher notices). Mode B (PPID=1 reparent): add EXIT/INT/TERM trap that kills the process group (kill 0). When the parent shell exits, the trap fires and tears down fswatch instead of leaving it orphaned under launchd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(watch-tasks): add HUP to trap signal list Per sonichi's review — if launchd ever sends SIGHUP to the watcher, fswatch would orphan without this. 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: Susan Xueqing Liu <xliu127@stevens.edu> * perf(discord-voice): hoist mkdirSync + switch appendFileSync to createWriteStream (#1286) * perf(discord-voice): hoist mkdirSync + switch appendFileSync to createWriteStream Closes #1053 - Hoist mkdirSync for DISCORD_VOICE_LOG dir to module-init alongside existing DATA_DIR/RESULTS_DIR/TASKS_DIR creates — eliminates syscall on every log line - Replace appendFileSync with a persistent WriteStream opened once at startup; write via stream.write() (non-blocking) instead of blocking appendFileSync - Stream errors set _opLogStream to null → auto-degrades to stdout-only (same fail-soft semantics as before, now without blocking the event loop) - appendFileSync kept in imports: still used by appendConversationLog (low-freq) * test(discord-voice): 11 structural tests for log-stream refactor (#1053) Adapted from sonichi/sutando#1196's structural test. The fallback-to- appendFileSync check is intentionally omitted — Chi's #1286 (cherry- picked above) chose fail-soft (`_opLogStream?.write(...)`, silent no-op on null) over explicit appendFileSync fallback. Both designs fix the perf issue; this test validates Chi's chosen shape. Verifies the structural invariants of the refactor: - createWriteStream imported, _opLogStream declared at module level - mkdirSync(dirname(DISCORD_VOICE_LOG)) hoisted to module init - appendOperationalLog hot path: no mkdirSync, uses _opLogStream - Stream error handler registers null-on-error (graceful degradation) - Existing mkdirSync calls for DATA/RESULTS/TASKS dirs preserved Original perf-fix attribution stays on Chi's commit; this test commit adds the verification harness so future regressions are caught in CI. Co-Authored-By: Lucy-Studio-Susan <noreply@anthropic.com> --------- Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> Co-authored-by: Lucy-Studio-Susan <noreply@anthropic.com> * fix(inline-tools): gate slideControlTool/fullscreenTool on presenter-mode sentinel (#1320) * fix(inline-tools): gate slideControlTool/fullscreenTool on presenter-mode sentinel (#1171) Registering these tools globally causes Gemini to fire slide_control and fullscreen on unrelated utterances (greetings, casual questions). Gate exposure via existsSync on the presenter-mode.sentinel file so they only appear in the tool registry when a presentation session is actually active. Both inlineTools and ownerOnlyTools arrays updated with the same conditional. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(inline-tools): 7 structural tests for #1171 presenter-mode gate Verifies the structural invariants of Chi's fix (cherry-picked above): - _presenterActive declared at module load via existsSync(join(WORKSPACE_DIR, 'state', 'presenter-mode.sentinel')) - inlineTools spreads ...(_presenterActive ? [slideControlTool, fullscreenTool] : []) - ownerOnlyTools spreads same - no bare slideControlTool/fullscreenTool references outside the gated spread - #1171 issue reference present in gate comment - _presenterActive declared before inlineTools array literal Adapted from Bassil's #1256 test but rewritten to match Chi's chosen shape (`_presenterActive` private const + inline join, no exported PRESENTER_SENTINEL constant). 7/7 tests pass against current branch HEAD. Co-Authored-By: Lucy-Studio-Susan <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * fix(bridges): single-instance guard via fcntl.flock (closes #1257) (#1317) * fix(bridges): single-instance guard via fcntl.flock (closes #1257) Duplicate bridge instances caused real failures (2026-05-xx incident): - telegram-bridge: API 409 errors from racing getUpdates pollers - discord-bridge: same task delivered twice via dm-fallback Root cause: startup.sh pgrep guard and Sutando.app launchd agent have a race window — whichever launches second doesn't see the first in time. Fix: new `src/single_instance.py` helper acquires an OS-level exclusive flock on `$WORKSPACE/state/locks/<name>.lock` at each bridge's entry point. LOCK_NB means contention exits immediately (exit 0) rather than blocking, so launchd KeepAlive doesn't restart-loop. Lock auto-releases on process death (OS closes all FDs). Each bridge calls acquire() once: - telegram-bridge: top of main() - slack-bridge: top of main() - discord-bridge: daemon path only (not the `send` subcommand) Lock file stores the holding PID for debugging. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(single-instance): 4 behavioral tests for acquire() lock semantics Covers: (a) first acquire writes PID, (b) second process exits 0, (c) lock releases after holder dies, (d) different names are independent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(quota-tracker): burn-rate EWMA + passes-left forecast (closes #1087) (#1319) * feat(quota-tracker): burn-rate EWMA + passes-left forecast (closes #1087) Adds predictive exhaustion tracking to read-quota.py so the proactive loop can tier on "will I run dry before reset at current rate?" rather than only current remaining %. Implementation: - After each read, loads state/quota-burn-history.json and computes the per-5min utilization delta against the last reading (gap 2min–2h only; outside that window the delta is too stale or duplicate). - Maintains a running EWMA (alpha=0.3, ~3-sample half-life) of deltas normalised to a "per 5-min pass" rate. - Skips negative deltas (5h reset happened — would corrupt the rate). - Reports burn rate + estimated passes/minutes left once ≥2 samples exist (first pass seeds history, second pass produces the first estimate, which is then smoothed on subsequent calls). - History is written atomically (tmp → rename) to avoid corrupt reads. Output (human-readable, when ≥2 samples exist): Burn rate: 2.1%/pass (5 samples) Est. passes left: 37.6 (~188m) Output (--json): `burn` key added to result object. No change to --gate behaviour. Also narrows `except Exception → except ImportError` on the workspace_default import (closes #1062, previously separate PR). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(quota-tracker): 11 tests for burn-rate EWMA (closes #1087 test gap) Covers _load_burn_history, _save_burn_history, and _update_burn_rate: - missing / corrupt history file → {}; save+load roundtrip - first call → None (no EWMA yet) - gap < MIN_GAP_S (2 min) → sample skipped - valid gap, 1 sample → None (needs ≥2) - 2 valid samples → forecast dict with all expected keys - gap > MAX_GAP_S (2 h) → sample skipped - decreasing utilization (quota reset) → sample skipped - EWMA smoothing formula verified (alpha=0.3, flat rate → stable ewma) - estimated_minutes_left ≈ passes_left × 5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * test(ci): lint for PEP-604 union annotations without __future__ (closes #961 option-A) (#1305) Adds `tests/python39-union-annotations.test.py` — an AST-based gate that fails when any `src/*.py` file uses `X | Y` union annotation syntax without `from __future__ import annotations`. Root cause of the #960 incident: telegram-bridge.py used `task_id: str | None = None` without the future import. On Python 3.9 (still the stock macOS `/usr/bin/python3`) that raises TypeError on import, silently killing the bridge. Detection uses `ast.walk` to find `BinOp(BitOr)` nodes in annotation context (function params, return types, variable annotations). String literals, regex patterns, and bitwise OR in expressions are not annotation nodes and don't trip the gate. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ci): host-CLI dependency snapshot + migrate 2 hardcoded ~/.claude/ paths (closes #864) (#1324) Adds tests/host-cli-dependency-snapshot.test.py — static-analysis gate that catches new hardcoded Path.home()/'.claude' constructions outside the canonical claudeHomePath() / claude_home_path() helpers. Also migrates the 2 violations the test discovered: - src/voice-agent.ts: bootstrapMemoryDir() now uses claudeHomePath() - src/voice-context.ts: defaultMemoryDir() now uses claudeHomePath() Both migrated paths now respect $CLAUDE_HOME override (useful for test isolation and alt-host installs), which the previous homedir() pattern silently ignored. 5 known pre-existing violations (health-check, bridges, dm-result) are allowlisted in the test — to be migrated in a follow-up. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * feat(open-sutando-ref): fuzzy GitHub-ref resolver skill (#903) (#1308) * feat(open-sutando-ref): fuzzy GitHub-ref resolver skill (#903) Resolves natural-language and numeric GitHub references to URLs without requiring the user to know exact PR/issue numbers. Handles `#874`, `PR 874`, `issue 874`, and free-text fuzzy queries via `gh pr/issue list --search`. Auto-detects PR vs issue for bare numbers. Supports `--open` to navigate in the default browser. Repo defaults to `gh repo view` output, overridable via `SUTANDO_GH_REPO`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(open-sutando-ref): 16 unit tests for parse_ref() + resolve_numeric() Pure-function tests that lock in parsing behaviour without network or gh CLI. Covers numeric (#874, 874), PR (PR 874, pull request 874, PR#874), issue (issue 874, issue #874), and fuzzy-description forms. Also pins the URL format from resolve_numeric(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * docs(CLAUDE.md): add Slack access control section mirroring Discord (closes #895) (#1293) Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu> * Add /email-find skill for stubborn email lookups (#1020) * Add /email-find skill for stubborn email lookups When an obvious Gmail search fails, the model used to confidently report "no email found" — even when the owner knew one existed. This skill codifies the playbook that actually works on this account: broad-before-narrow scans, partner-domain fanout (Soulilution for K12, etc.), and re-walking long threads that get_thread truncates. Lesson learned today (2026-05-22): K12-related replies ride on the `Re: Fwd: Highland Fleets - Contact Data` thread from data.ops@ soulilution.com — no K12 token in the subject, sender isn't k12-data.com. Two cycles of "I can't find it" before owner-cited subject fragment finally surfaced the email. Key behavioral rule embedded in the skill: do NOT give up after one or two failed queries when the owner has high confidence. Run all four phases, then ask for clarification — never sooner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * De-personalize email-find skill for cross-user value Owner feedback: the v1 skill hardcoded vasiliy@ag2.ai in the broad-scan query and shipped a partner-domain map full of one user's specific customers (K12, Soulilution, Nokia, DNAnexus). That's user-specific context that doesn't belong in the skill repo. Changes: - Replace `to:vasiliy@ag2.ai` with `to:me` (Gmail's reserved keyword for the authenticated user — works for every Gmail account). - Replace the hardcoded partner-domain table with an instruction to read it from per-user memory (suggested file: `$SUTANDO_MEMORY_DIR/reference_partner_domains.md`). - Document the file format inside the skill so users / agents know what to write. - Add "offer to create the file" behavior when 2+ mappings are discovered in-session. - Generalize all examples (Topic-X instead of K12, Acme Corp / Foo Foundation as illustrative entries). - Update docs/built-in-tools.md pointer accordingly. Behavioral rules unchanged: 4-phase workflow, broad-before-narrow, don't-give-up-after-2-failed-queries, re-walk truncated threads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * email-find: gracefully degrade + offer setup when partner-domains file is absent Owner pointed out that v2 of the skill implicitly assumed users would discover the $SUTANDO_MEMORY_DIR/reference_partner_domains.md convention on their own. That's a footgun for first-time users. Fixes: - Phase 2 now explicitly says "skip if no partner-domain file exists" rather than implying it's required. - New "## Where the file lives" subsection spells out the lookup order: env var → default path → file patterns to accept → degrade if absent. - New "## First-time setup flow" describes the user-prompted "want me to save this mapping for next time?" interaction, with a concrete example of the offer text. - Removed the duplicate "offer to create it" sentence at the bottom of File Format. Net behavior: the skill works on a fresh install with zero setup (Phases 1, 3, 4 do the work). After 2+ discoveries it offers to persist the map so future invocations get the Phase 2 acceleration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * email-find: silent auto-learn + timestamped rows + 365-day prune Owner feedback 2026-05-22: "don't make user do the work — don't ask for confirmation, but learn. ... Worst case scenario you will do extra search. Make sure to add timestamps to memory ... use it to prune memories — delete what was not useful for a year." Changes: - Replaced "First-time setup flow (offered, not required)" with "Auto-learn on discovery (no user confirmation)" — the skill now silently writes new mappings, never prompts. - File format gains two timestamp columns: first_seen + last_useful. - New "Timestamps and pruning" section: drop rows whose last_useful is >365 days old at next read; pruning runs at most once per day via a pruned_at header. - Updated Phase 2 to reference the new auto-learn section. The principle is broader than email-find — also saved as a feedback memory (feedback_silent_auto_learn.md in owner's memory dir) so it applies to any future skill that discovers facts about the user's environment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * email-find: tighten skill doc for long-term maintainability Remove iteration-related cruft and redundant restatements: - Replace owner-specific K12/soulilution example with generic Acme/data-ops phrasing. - Drop the "29+ messages" specific count from the truncation rule — generalize to "long threads." - Collapse "Where the file lives" 4-step numbered list to one paragraph; the resolution rule is the same in any number of words. - Merge "Auto-learn on discovery" paragraph + "Implementation notes" bullet list — they were saying the same thing twice. - Replace literal session-date timestamps in the file-format example with YYYY-MM-DD placeholders. - Drop the Don't section's restatement of behavioral rules; keep the three items that aren't already covered by the numbered rules. 138 lines, down from 159. Same behavior, less cruft. 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: Susan Xueqing Liu <xliu127@stevens.edu> * fix(self-diagnose,catchup): workspace-first read for build_log + pending-questions (#1264) gather.sh: build_log.md and pending-questions.md moved to workspace on 2026-05-18 (#762) but the reads were still hardcoded to $REPO. On any host where $REPO/build_log.md doesn't exist (migrated installs), self- diagnose snapshots silently went empty. Fix: resolve workspace into $WS, prefer $WS/$file, fall back to $REPO. catchup-after-startup.sh: inverted priority — checked $REPO/build_log.md first, only falling through to $WS when missing. Swapped to workspace- first to match the contract (build_log.md is a workspace file, not repo). Addresses items 3+4 from Lucy's post-migration aud…
…hi#1335 sub-PR-2) Sub-PR-2 of the sonichi#1335 unification: factor out the identical _recover_orphan_sending_files() body from discord-bridge.py, telegram-bridge.py, and slack-bridge.py into shared module src/proactive_delivery.py. Closes the sonichi#1046 duplication pattern at its source — the bug-fix introduced in sonichi#1046 (orphan .sending recovery) was applied to 3 separate files because no shared helper existed. Future fixes to the sweep logic (TTL tuning, instance-aware claim resolution, etc.) land in one place. Changes: - src/proactive_delivery.py (new) — recover_orphan_sending_files() - src/discord-bridge.py — local _recover_orphan_sending_files() now delegates to shared impl, pinning results_dir to RESULTS_DIR - src/telegram-bridge.py — same - src/slack-bridge.py — same - tests/proactive-delivery.test.py (new) — 7 unit tests covering: basic recovery, non-proactive skip, collision skip, missing-dir, empty-dir, idempotent, multi-orphan Test plan (all green): - Python (proactive-delivery): 7/7 - Python (task-archive): 11/11 (still pass) - Parity (task-archive): 4/4 (still pass) - TS (task-archive): 12/12 (still pass) - tsc --noEmit: clean - py_compile on all 3 modified bridges: OK Python-only — no TS counterpart (proactive delivery is bridge-side only; TS voice agents don't deliver proactive messages). Refs: sonichi#1335 sonichi#1046 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug
Both bridges atomic-claim
results/proactive-*.txtfiles viarename(proactive-X.txt → proactive-X.sending)before sending. The rename prevents same-tick double-deliveries between concurrent poll iterations.If the bridge crashes BETWEEN the rename and the delivery, the
.sendingfile sits orphaned inresults/forever — no poll iteration ever looks at.sendingsuffixes. The owner notification is silently dropped until next manual intervention.Real loss vector under any unexpected bridge restart (launchd auto-restart on crash, manual kill, system reboot mid-tick). Symptom is silence — no operator signal surfaces the failure.
Fix
Both bridges sweep orphan
.sendingfiles at startup, renaming back to.txtso the normal claim-and-deliver flow picks them up:src/discord-bridge.pyon_ready()before poll-loop creation (also fires on full reconnect / IDENTIFY)src/telegram-bridge.pymain()before the poll loop startsDefensive shape: only
proactive-*.sendingfiles recovered; collision-guard (don't overwrite newer.txt); per-file failures logged but don't block bridge startup; idempotent; missingresults/dir handled.Tests
tests/bridges-sending-orphan-recovery.test.py— 8 cases exercising both bridges' helpers directly:.sendingignored.txtexists) skippedresults/dirAll 8 pass locally.
Test plan
python3 tests/bridges-sending-orphan-recovery.test.py— 8/8 passresults/proactive-test.sendingby hand, restart bridge, verify it's renamed back and delivered🤖 Generated with Claude Code