Skip to content

fix(bridges): recover orphan .sending files on startup (restart-safety) - #1046

Merged
liususan091219 merged 2 commits into
sonichi:mainfrom
VasiliyRad:fix/bridges-sending-orphan-recovery
May 29, 2026
Merged

liususan091219 merged 2 commits into
sonichi:mainfrom
VasiliyRad:fix/bridges-sending-orphan-recovery

Conversation

@VasiliyRad

Copy link
Copy Markdown
Contributor

The bug

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 crash, manual kill, system reboot mid-tick). Symptom is silence — no operator signal surfaces the failure.

Fix

Both bridges sweep orphan .sending files at startup, renaming back to .txt so the normal claim-and-deliver flow picks them up:

Bridge Call site
src/discord-bridge.py on_ready() before poll-loop creation (also fires on full reconnect / IDENTIFY)
src/telegram-bridge.py 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 bridge startup; idempotent; missing results/ dir handled.

Tests

tests/bridges-sending-orphan-recovery.test.py — 8 cases exercising both bridges' helpers directly:

Case Pins
discord-bridge recovers a single orphan Core feature
telegram-bridge recovers a single orphan Parity
no-op when no orphans Common case
multiple orphans in one pass Multi-file crash scenario
non-proactive .sending ignored Defensive opt-in
collision (target .txt exists) skipped Don't overwrite newer
second call is no-op Idempotency
missing results/ dir First-ever run

All 8 pass locally.

Test plan

  • python3 tests/bridges-sending-orphan-recovery.test.py — 8/8 pass
  • Manual: create results/proactive-test.sending by hand, restart bridge, verify it's renamed back and delivered

🤖 Generated with Claude Code

@amkunte

amkunte commented May 23, 2026

Copy link
Copy Markdown
Contributor

CI is failing because discord-bridge.py reads DISCORD_BOT_TOKEN only from ~/.claude/channels/discord/.env at module-import time — it ignores os.environ. Your test correctly sets the env var before import, but the bridge never checks it.

Root fix is in #1050 (fix(discord-bridge): env-var-first token load — CI green). Once that merges to main, rebasing this branch will pick it up and CI should pass.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 setdefault works as intended; or
  • make this test self-contained — write a temp .env under a HOME/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.

qingyun-wu added a commit that referenced this pull request May 23, 2026
…#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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Symmetric two-bridge fix in one PR — src/discord-bridge.py:2021-2071 and src/telegram-bridge.py:404-433 close the same bug class with the same defensive shape. Easier to reason about as a pair than two separate PRs would be.
  2. Collision guard at src/discord-bridge.py:2050-2057 (if target.exists(): … continue). The atomic-claim invariant says .txt and .sending of the same name shouldn't coexist, but if an operator hand-drops the .txt back in, the recovery refuses to clobber. Pairs with the "don't overwrite newer" line in your test.
  3. 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.
  4. 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 .sending pattern (e.g. task-*.sending), they don't accidentally inherit this sweep without an explicit code change. Pinned by test_non_proactive_sending_files_ignored.
  5. 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.
  6. on_ready placement — src/discord-bridge.py:2023 runs the sweep BEFORE poll_results/poll_proactive start, so no race between recovery and the first poll tick claiming a freshly-recovered file.

Non-blocking observations

  1. Decorator-orphan issue (Qingyun already flagged as blocking). At src/discord-bridge.py:2020-2021, the new sync helper is inserted between @client.event and async def on_ready. With real discord.py this raises TypeError at import (event requires a coroutine) and on_ready loses its registration — so neither recovery nor the poll loops would run in production. The test stub at tests/bridges-sending-orphan-recovery.test.py:48 defines event = staticmethod(lambda fn: fn) which masks this. Move the helper definition above @client.event (any non-decorator position works). This is the blocker.
  2. Token-load race already resolved upstream. src/discord-bridge.py:70 now reads DISCORD_BOT_TOKEN from env first (PR #1050, merged 2026-05-23 04:21 UTC). Rebase on main and the test's os.environ.setdefault finally 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.
  3. telegram-bridge.py recovery 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.
  4. Sentinel sweep covers proactive only — not task-*.sending. PR #1048 introduces a separate state/discord-delivered/ sentinel for poll_results; that path doesn't go through .sending rename 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.
  5. 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 in on_ready if any two land before the others rebase — each PR adds a line at src/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 .sending sweep, 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 before bridges-sending-orphan-recovery.test.py alphabetically as bri…… wait, actually bri… < dis… so this test sorts FIRST). The bri… test runs before any sibling test writes a .env to disk, so its os.environ.setdefault is 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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

VasiliyRad added a commit to AG2Platform/stando that referenced this pull request May 23, 2026
… 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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@VasiliyRad
VasiliyRad force-pushed the fix/bridges-sending-orphan-recovery branch from df34073 to b5173b1 Compare May 23, 2026 20:44
sonichi added a commit that referenced this pull request May 23, 2026
… + 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>
sonichi added a commit that referenced this pull request May 23, 2026
… + 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>
sonichi added a commit that referenced this pull request May 23, 2026
…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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. src/slack-bridge.py has the same bug, not covered. slack-bridge.py:601 has claim = f.with_suffix(".sending") — same atomic-claim pattern. A crash between rename and chat.postMessage orphans the file identically. The PR body lists discord-bridge.py + telegram-bridge.py only 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.

  2. 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.py or similar) starts to pay off. Not blocking — duplication is local and well-commented — but worth a follow-up issue.

  3. Discord on_ready fires 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 = False guard so the sweep only runs on the FIRST on_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).

  4. Manual test plan item left unchecked. "Manual: create results/proactive-test.sending by 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

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Cold review (Bassil's Sutando node):

LGTM — clean, well-scoped fix for a real silent-failure class.

What I verified:

  • ✓ Recovery runs in on_ready() BEFORE the discord-bridge poll loop is created, and in main() BEFORE the telegram poll loop starts. The ordering guarantee eliminates the race where a recovered file could be double-claimed by a leftover poll tick.
  • ✓ Correctly scoped to proactive-* only — task-* result files are consumed by a different code path (task-bridge result watcher) and correctly excluded.
  • ✓ Collision guard is defensive but correct: proactive-X.txt and proactive-X.sending coexisting is theoretically impossible under the normal atomic-claim invariant, but a manual operator drop could create it. Skip-with-log is the right call.
  • ✓ Per-file FileNotFoundError catch handles the "lost the race to another process" edge case (two bridge instances on startup, extremely unlikely but safe).
  • ✓ Idempotent: second call at on_ready() (Discord reconnect) sees no .sending files and is O(readdir) no-op.
  • ✓ 8 tests cover all the important scenarios including multi-file, collision, idempotency, and missing results/ dir.

One non-blocking observation: The slack-bridge is not updated here. If src/slack-bridge.py uses the same proactive-*.txt → .sending atomic-claim pattern, it has the same orphan risk. If it doesn't use that pattern (Slack polling works differently), no change needed — worth a one-line comment in the PR description confirming the scope decision.

Ready to merge.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Follow-up: slack-bridge has the same orphan gap (line 606 uses proactive-*.txt → .sending). Fixed on bassilkhilo-ag2/sutando:fix/slack-bridge-orphan-recovery (commit 46984b4), 9 tests. Push-ready pending Bassil's go-ahead.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .txt that somehow re-appeared), only proactive-*.sending swept (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):

  1. Duplicated function across both bridges. _recover_orphan_sending_files() is verbatim-copied — same docstring, same body — in both src/discord-bridge.py and src/telegram-bridge.py. Same drift-risk pattern that #1116 addressed for stand-identity loading. A small src/bridge_recovery.py:recover_orphan_sending_files(results_dir, log_fn=print) -> int exported 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.

  2. on_ready race window on Discord reconnect. discord-bridge calls _recover_orphan_sending_files() inside on_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 .txt and re-claims → potential double-send. The target.exists() guard catches the case where the in-flight send already wrote a new .txt for 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 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • FileNotFoundError catch 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 and main() in telegram-bridge, both before poll loops start — correct placement.

The stando counterpart (stando #60) uses the same approach. Consistent. LGTM.

@jgluck96 jgluck96 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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 sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

VasiliyRad and others added 2 commits May 28, 2026 21:36
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.
@liususan091219
liususan091219 force-pushed the fix/bridges-sending-orphan-recovery branch from b5173b1 to 5f304c2 Compare May 29, 2026 01:36
@liususan091219
liususan091219 merged commit 90aa14a into sonichi:main May 29, 2026
2 checks passed
liususan091219 added a commit that referenced this pull request May 29, 2026
…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>
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 30, 2026
…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>
amkunte added a commit to amkunte/sutando that referenced this pull request May 30, 2026
…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…
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request Jun 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants