Skip to content

fix(inline-tools): gate slide_control at exposure on presenter-mode sentinel (#1171) - #1256

Closed
bassilkhilo-ag2 wants to merge 1 commit into
sonichi:mainfrom
bassilkhilo-ag2:fix/inline-tools-slide-presenter-gate-1171
Closed

bassilkhilo-ag2 wants to merge 1 commit into
sonichi:mainfrom
bassilkhilo-ag2:fix/inline-tools-slide-presenter-gate-1171

Conversation

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator

Summary

  • slideControlTool was unconditionally registered in inlineTools and ownerOnlyTools, exposing its description ("next slide", "previous slide", "go back") to Gemini on every voice turn including greetings
  • Gemini fired spurious tool_call rows in voice sqlite — 3 unprovoked copres_next_anchor calls in <40s on a "Can you hear me?" session (Lucy's observation 2026-05-26)
  • Root cause pattern: inline-tool always-exposure + intent-check-too-late; tool fires before the guard runs → sqlite noise + wasted tokens

Fix

Gate at exposure time, not effect time. At module load, read state/presenter-mode.sentinel via existsSync. If absent, slideControlTool is excluded from the tool arrays Gemini sees — it can't fire what it doesn't know about. fullscreenTool stays ungated (generic OS action, useful outside presentations).

const PRESENTER_SENTINEL = join(WORKSPACE_DIR, 'state', 'presenter-mode.sentinel');
export const presenterModeActive = existsSync(PRESENTER_SENTINEL);

// in inlineTools / ownerOnlyTools:
...(presenterModeActive ? [slideControlTool] : [])

Test plan

  • 6 structural tests in tests/inline-tools-presenter-gate.test.ts — all passing
    • presenterModeActive exported and derived from existsSync(PRESENTER_SENTINEL)
    • PRESENTER_SENTINEL points to state/presenter-mode.sentinel under WORKSPACE_DIR
    • slideControlTool spread conditionally in both inlineTools and ownerOnlyTools
    • No bare slideControlTool reference in either array
    • Issue #1171 referenced in gate comment
    • presenterModeActive declared before slideControlTool definition

Closes #1171

🤖 Generated with Claude Code

…entinel (sonichi#1171)

slideControlTool was unconditionally registered in `inlineTools` and
`ownerOnlyTools`, so Gemini saw its description ("next slide", "previous
slide", "go back") on every voice turn — including greetings — and fired
spurious tool_call entries in voice sqlite (3 unprovoked copres_next_anchor
calls in <40s on a "can you hear me?" session, per Lucy's observation on
2026-05-26).

Gate at exposure time: check `state/presenter-mode.sentinel` at module
load and spread slideControlTool into the tool arrays only when the sentinel
is present. Gemini can't fire what it doesn't see. fullscreenTool stays
ungated — it's a generic OS action useful outside presentations.

6 structural tests added to tests/inline-tools-presenter-gate.test.ts.

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

Copy link
Copy Markdown
Collaborator Author

Closing per owner instruction — autonomous PR creation halted.

@VasiliyRad VasiliyRad 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 on the approach — gating at exposure time (module load) is the right pattern for the spurious tool_call class. If Gemini doesn't see the tool description, it can't fire it. Sentinel-as-gate is also reusable for other presenter-only tools later.

One semantic note worth flagging, not blocking:

Module-load-time evaluation means presenter mode activated mid-voice-session is invisible until process restart. presenterModeActive is computed once when inline-tools.ts is imported, then the spread ...(presenterModeActive ? [slideControlTool] : []) is frozen for the agent's lifetime. Flow that breaks:

  1. user starts voice
  2. mid-call: bash scripts/presenter-mode.sh start 60
  3. user says "next slide" — Gemini doesn't see slide_control (load-time check was false), tool unavailable until restart

For the common workflow (presenter mode set BEFORE voice starts), the fix works as intended. The mid-session edge case is the cost.

Two options if mid-session activation matters:

  • (a) Re-check sentinel each turn: getter instead of constant, with a small disk-stat cost per build_agent — acceptable since existsSync is microsecond-class.
  • (b) Notify presenter-mode.sh to bump voice-agent or write a "reload tools" signal voice picks up.

Probably not worth either right now — issue #1171 is the priority — but worth noting in a follow-up if the mid-session flow turns out to matter.

Closes #1171 — confirmed.

liususan091219 added a commit that referenced this pull request May 29, 2026
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>
liususan091219 added a commit that referenced this pull request May 29, 2026
…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>
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…
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

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

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

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

* feat(room-doc): an agent can read who wrote what

The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

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

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
…4494)

* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

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

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

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

* feat(room-doc): an agent can read who wrote what

The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

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

* feat(room-doc): kanban rules for agents, and the convergence answer

The Kanban panel (cinny #1112) gives a room a third document kind. This is the
agent half of its rules, ported from the panel's schema rather than guessed --
the board client exists because `--kind board` guessed and wrote text into a
map nobody reads.

  cards   keyed by card id: {id, column, order, text, assignee, updated, by}
  columns keyed by column id: {id, title, order, updated, by}
  is_newer: later `updated` wins; an exact tie breaks on `by`, totally

It answers the question the live two-client test was for, and answers it
deterministically, with the client ids forced so the race is decided rather
than drawn:

  agent's move is NEWER in both rounds, with NO re-assertion by the agent:
    agent holds the higher client id -> kept 'doing'  (agent won)
    agent holds the lower  client id -> kept 'todo'   (agent LOST)

So an agent that writes once and walks away converges BY LUCK. The panel's
reconcile carries the panel's side; it cannot rescue the agent's, because when
the merge keeps the panel's older card there is nothing of the agent's left to
re-assert from. Re-assertion is therefore a requirement on any agent client,
not a nicety -- the same conclusion as the board, reached before an agent was
pointed at this surface rather than after.

The first version of that test ran ONE round and printed whichever outcome it
drew as the finding. It drew the agent winning. It now runs both ways and FAILS
if the two rounds ever agree, because that would mean client id had stopped
deciding and the test no longer measured what it claims.

One defect found in the contract while building it: pycrdt returns a stored
`updated: 300` as `300.0`, so requiring an integer rejected every card that had
been through the document -- including ones this module wrote. Nothing would
ever have been re-asserted. An integral float is accepted now; `300.5`, `"300"`
and `True` are still refused, because the schema says milliseconds.

Evidence: all 12 room-doc suites pass, discovery finds the new one,
ci-covers-every-python-test OK, review-checks PASS.

Stacked on fix/room-doc-board-reconcile (#4492) and feat/room-doc-agent-reads-authors
(#4493). Merge order: #4492, #4493, this.

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

* fix(room-doc): only markdown is a text — every other kind refuses

The board fix named ONE kind, so the next kind inherited the whole defect. With
a kanban document open:

  read   -> ''                 an agent sees an empty board
  append -> SUCCEEDED          text written into a board of cards

That is the exact failure the board guard was written to stop, reachable again
the moment a third kind existed. I fixed the instance and not the class.

Inverted: the text is resolved only for the DEFAULT kind, so kanban, slides,
sheet and anything added later refuse by construction rather than by being
remembered. The refusal names what the document actually holds.

Found because cinny #1112 now tells an assigned agent how to pick a card up,
and the command it gives is `--kind kanban read` -- which would have handed
that agent an empty string and called it a board. The instruction was right;
this client was wrong.

Mutation: restoring the board-only spelling is CAUGHT.

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

* feat(room-doc): kanban deletion is a tombstone, and an agent must read it

The panel deletes by writing `{...card, deleted: true, updated, by}` and KEEPS
the key: `map.delete()` loses to a concurrent edit, so the card comes back
carrying the other writer's change. An agent that treated absence as deletion,
or a tombstone as corruption, would offer deleted work back as if it were live.

  is_card      a tombstone is still a well-formed card; a junk flag is not
  live_cards   what is actually on the board
  in_column    tombstones excluded, so listing work never returns a deleted card
  delete_card  writes the tombstone, because a deletion has to win a race

Note the spelling: the board says `isDeleted`, kanban says `deleted`. Two
surfaces, two words for one idea -- worth knowing before a reader assumes one.

Evidence: a later deletion beats an earlier move, a later move beats the
deletion, and `deleted: "yes"` / `deleted: 1` are refused while `False` is not.

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

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

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

* feat(room-doc): a kanban card whose column is gone is surfaced, not lost

Deleting a column does not delete its cards. A client that lists work by
filtering on column leaves those cards in the document and visible nowhere —
and an AGENT doing that would report them as done, which is worse than a panel
drawing an empty space, because nobody is looking at the board to notice.

`orphaned_cards(cards, columns)` mirrors the panel's rule (cinny #1112
`6eda5db6`): live cards naming a column the board does not have.

A tombstoned orphan stays deleted. Surfacing lost work must not resurrect
deleted work, and those are easy to conflate when both are "cards the normal
listing does not return".

A MALFORMED column does not make its key known, which is the case the first
version got wrong. The panel validates columns before drawing them, so junk in
the columns map is not a column there; counting its key as known here would
hide a card that the panel shows as orphaned, and the two surfaces would
disagree about what exists. Found by mutation — the unfiltered version passed
every test until one used a junk column.

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

* test(room-doc): cover every kanban refusal, and delete a branch that cannot run

The coverage gate named real gaps, not noise: `room_kanban.py` at 79.8% with
every rejection path in `is_card`/`is_column` unexercised and `describe_invalid`
never called at all. Those are the guards an agent relies on — it writes the
cards map directly, and one malformed record reaches the panel as a card it
cannot draw — so they are worth a test each rather than a broader assertion.

room_kanban.py is now 100%.

The other complaint, `room_doc_client.py` lines 149-150, was a `try/except`
around `doc.get(AUTHORS_KEY, type=Map)`. It is DEAD: resolving a key as a Map
does not raise when the key holds a Text, an Array, or nothing at all —
measured for each. The comprehension's `isinstance(v, dict)` already handles a
junk value.

So it is removed rather than covered. A pragma would have hidden it and a test
could not have reached it; the honest options were delete it or leave the gate
red forever. The behaviour it was guarding is still pinned: a wrongly-typed
authors key yields no attribution rather than a crash.

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

* fix(room-doc): _int refuses NaN and the infinities instead of raising

`value == int(value) and value == value` evaluates left to right, so `int()` ran
FIRST and raised on exactly the three inputs the guard existed for. The NaN
check was unreachable for NaN.

  _int(nan)  -> ValueError        is_card({... "updated": nan}) -> ValueError
  _int(inf)  -> OverflowError
  _int(-inf) -> OverflowError

`is_card` exists because an agent writes the cards map directly and one
malformed record reaches the panel as a card it cannot draw. Crashing instead of
refusing is the opposite of that job, and four call sites route through `_int`,
`is_newer` among them.

Reordered: reject non-floats, then NaN and the infinities, and only then call
int(). Same shape as `room_doc_board.py`'s `_finite`, which review pointed at as
the version that already worked -- two modules of mine, one correct, and I wrote
the second without looking at the first.

  nan/inf/-inf -> None      300 -> 300     300.0 -> 300
  300.5 -> None             True -> None   "300" -> None
  is_card(updated=nan) -> False (not a raise); is_newer through nan -> False

Mutation: restoring the original ordering is CAUGHT.

Found by review at 0ec7257, re-run at that head rather than read off the diff.

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

---------

Co-authored-by: Claude Opus 5 (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.

voice-agent: copres_next_anchor fires on greetings — inline tools needing mode-gating should gate at exposure, not just effect

2 participants