Skip to content

fix(workspace): honor SUTANDO_WORKSPACE in 5 TS sites — round 3 audit, first TS slice (issue #839) - #842

Merged
sonichi merged 2 commits into
mainfrom
fix/ts-workspace-audit
May 18, 2026
Merged

sonichi merged 2 commits into
mainfrom
fix/ts-workspace-audit

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Summary

First TS slice of the workspace audit followup filed as #839. 5 bug sites across 2 files, same class as #836 / #833 / #838 (Python side, all merged today): code paths resolving runtime data against process.env.WORKSPACE_DIR (wrong env var name — should be SUTANDO_WORKSPACE) or process.cwd() instead of the canonical resolveWorkspace() helper introduced in #821.

Sites fixed

File Site Pre-fix behavior
src/cartesia-tts.ts:29 getWorkspace WORKSPACE_DIR env (legacy, not set) → process.cwd() fallback. TTS audio files (results/audio/<category>/<label>-<ts>.wav) written to whatever cwd the launcher had.
src/inline-tools.ts:471, 472 cancel_task tasksDir/resultsDir looked in <cwd>/tasks/. Voice-initiated cancels silently failed to find in-flight tasks on hosts with SUTANDO_WORKSPACE set — bridge writes to workspace, cancel_task was reading repo.
src/inline-tools.ts:775 show_view dynamic-content.json written to <cwd>/. Sutando.app (post-#837) reads from \$SUTANDO_WORKSPACE/. Voice toggle fired but the app never saw it.
src/inline-tools.ts:873 recent_context state/voice-session-context.json looked in <cwd>/state/. Core writer (CLAUDE.md "Voice session context") writes to \$SUTANDO_WORKSPACE/state/. Voice-agent's deictic-pronoun recovery returned "no context recorded yet" even when populated.
src/inline-tools.ts:772 NOTES_DIR sharedPersonalPath('notes', process.cwd()) passed cwd as the workspace arg to the helper, so the fallback inside the helper resolved to <cwd>/notes/ instead of \$SUTANDO_WORKSPACE/notes/. read_note / search_notes looked in the wrong place.

Pattern (matches #821 + #836)

import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();

Module-init resolution. Single source of truth per module. Mirrors the Python WORKSPACE_DIR = resolve_workspace() pattern from the Python side of the audit.

Not addressed in this PR (still in #839's backlog)

  • src/voice-agent.ts:97, 103 — WORKSPACE_DIR (the canonical voice-agent reference) + CALL_RESULTS_DIR. Highest-traffic sites, used across ~10 call sites in voice-agent.ts (PIDFILE, tasks/, results/, briefings, conversation.log). Worth a focused review — saved for the next PR.
  • src/util_paths.ts:26, 54 — the workspace ?? process.cwd() fallback inside the helper. Cleaner to fix every caller first (this PR is one step toward that) and then either delete the fallback or switch it to resolveWorkspace() so the helper has a sensible default.
  • src/inline-tools.ts:957, 1021 — join(process.cwd(), 'skills'). These are CODE-adjacent paths (skills ship with the code), so they need REPO_ROOT semantics, not WORKSPACE_DIR. Latent today (the launcher always cwd's to the repo) but worth fixing.
  • src/mentra-bridge.ts:88 — file is currently untracked (WIP per the commit history).

Test plan

  • npx tsc --noEmit — clean.
  • Diff is targeted: 2 files, 22+/-8.
  • CI on PR.

Followups

After this lands, 3 of the 4 followup buckets in #839 remain (voice-agent core, util_paths fallback, skills-path REPO_ROOT semantics). Once all are addressed, #839 can close.

Audit running total: 18 bug sites across 10 files (#815 + #836 + #833 + #838 + this PR), in the workspace-vs-repo class.

…ols sites (issue #839)

Round-3 audit followup to #836/#833/#838 — first slice of the TS audit
flagged in #839. Five bug sites across two files, all hitting the same
class: code paths resolving workspace data against `process.env.WORKSPACE_DIR`
(wrong env var name) or `process.cwd()` instead of `resolveWorkspace()`.

**Sites fixed:**

- `src/cartesia-tts.ts:29` (`getWorkspace`) — TTS audio output dir
  (`results/audio/<category>/<label>-<ts>.wav`). Pre-fix: `WORKSPACE_DIR`
  env (legacy name, not set) → `process.cwd()` fallback → audio written
  to whatever cwd the launcher had. Now resolves to `$SUTANDO_WORKSPACE`.

- `src/inline-tools.ts:471, 472` (`cancel_task` tool) — `tasksDir` and
  `resultsDir` for the cancel-task lookup. The tool listed tasks from
  `<cwd>/tasks/` and wrote cancel results to `<cwd>/results/`. On hosts
  with `SUTANDO_WORKSPACE` set, the bridge writes tasks to the workspace
  but cancel_task was looking in the repo (or whatever cwd) — so voice-
  initiated cancels would never find an in-flight task and would silently
  write a stranded CANCEL_INSTRUCTION file.

- `src/inline-tools.ts:775` (`show_view` tool) — `dynamic-content.json`
  for the menu-bar app's view toggle. Pre-fix the inline tool wrote to
  `<cwd>/dynamic-content.json` while Sutando.app (post-#837) reads from
  `$SUTANDO_WORKSPACE/dynamic-content.json`. Split-brain: voice toggle
  fired but the app never saw it on hosts with `SUTANDO_WORKSPACE`.

- `src/inline-tools.ts:873` (`recent_context` tool) — voice-agent state
  context. Pre-fix the read path looked at `<cwd>/state/voice-session-context.json`
  but the writer (core via CLAUDE.md "Voice session context" section)
  writes to `$SUTANDO_WORKSPACE/state/`. Voice-agent's deictic-pronoun
  recovery ("what was the post?", "what's pending?") would return
  `{note: "no context recorded yet"}` even when the writer had populated
  the file.

- `src/inline-tools.ts:772` (`NOTES_DIR`) — `sharedPersonalPath('notes',
  process.cwd())` passes cwd as the workspace argument to the helper.
  When `SUTANDO_PRIVATE_DIR` isn't set, the helper falls through to
  `join(workspace, 'notes')` which becomes `<cwd>/notes/` instead of
  `$SUTANDO_WORKSPACE/notes/`. read_note / search_notes silently look
  in the wrong place.

**Pattern (matches #821):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution mirrors the Python `WORKSPACE_DIR = resolve_workspace()`
canonical pattern. Single source of truth per module.

**Not addressed in this PR (still in #839's backlog):**

- `src/voice-agent.ts:97, 103` — `WORKSPACE_DIR`/`CALL_RESULTS_DIR`. The
  highest-traffic sites; defer to a focused review.
- `src/util_paths.ts:26, 54` — the fallback inside the helper itself.
  Cleaner to fix every caller first, then either delete the fallback or
  switch it to `resolveWorkspace()` so the helper has a sensible default.
- `src/inline-tools.ts:957, 1021` — `join(process.cwd(), 'skills')`.
  These are CODE-adjacent paths (skills ship with the code), so they
  need REPO_ROOT semantics, not WORKSPACE_DIR. Latent today because the
  launcher always cwd's to the repo, but worth a separate PR.
- `src/mentra-bridge.ts:88` — file is currently untracked (WIP).

**Verified:**

- `npx tsc --noEmit` ✓ clean across the whole tree.
- Diff is targeted: 2 files, 22+/-8.
@qingyun-wu
qingyun-wu requested a review from sonichi May 18, 2026 19:21

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

LGTM — round-3 TS audit, clean.

5 migrations checked:

  • src/cartesia-tts.ts getWorkspace() — drops the WORKSPACE_DIR env var alias + process.cwd() fallback. resolveWorkspace() consolidates. ✓
  • src/inline-tools.ts cancelTaskTool tasksDir/resultsDir — was process.cwd()/tasks, now WORKSPACE_DIR/tasks. ✓ Same drift class as PR #821 callsites.
  • src/inline-tools.ts NOTES_DIR — sharedPersonalPath('notes', process.cwd()) → sharedPersonalPath('notes', WORKSPACE_DIR). Note the docstring update too — "else cwd/notes" → "else /notes". Good attention to comment-rot.
  • src/inline-tools.ts dynamic-content.json — was repo-root drop. Now workspace. ✓
  • src/inline-tools.ts VOICE_SESSION_CONTEXT_PATH — same shape. ✓

Bug-citation comment is the right shape: explains why process.cwd() "happened to match the workspace" pre-fix only when voice-agent was launched from the repo with SUTANDO_WORKSPACE unset. That's the silent-divergence story.

Non-blocking nit: the docstring says "first TS slice (issue #839)" — if there are more TS sites worth migrating, worth listing them in #839 explicitly so the round-N work is enumerable. Up to you.

Both checks green (tsc+tests SUCCESS, license/cla SUCCESS — second is empty in rollup but PR view shows it). MERGEABLE. Ship.

qingyun-wu added a commit that referenced this pull request May 18, 2026
…owup)

Migrates voice-agent.ts's `WORKSPACE_DIR` + `CALL_RESULTS_DIR` constants to
`resolveWorkspace()` (the canonical TS helper introduced in #821). Two
const definitions cover ~15 call sites — all of which use the path for
per-user runtime state (tasks/, results/, state/, conversation.log,
briefings, audio, dynamic-content, PIDFILE, etc.).

**Why this matters:**

On hosts with `SUTANDO_WORKSPACE` set (e.g. this machine, where
`SUTANDO_WORKSPACE=/Users/qingyunwu/Documents/github/sutando-workspace-001`),
the pre-fix behavior was:

1. `WORKSPACE_DIR = process.env.WORKSPACE_DIR || <repo-path-via-import.meta.url>`
   - `WORKSPACE_DIR` env var was the *legacy* name (now `SUTANDO_WORKSPACE`).
   - Fallback resolved to the repo checkout, so voice-agent wrote tasks,
     results, conversation.log, briefings, the PIDFILE, audio dirs, etc.
     to the **repo**, while Sutando.app (post-#837), Python bridges
     (post-#762), and #842 inline-tools (just landed) all wrote/read
     from the **workspace**.

2. `CALL_RESULTS_DIR = <repo>/results/calls` — phone-call result records
   landed under the repo too.

Net effect: split-brain on `SUTANDO_WORKSPACE`-enabled hosts. Phone-call
`latest-result.json` lived under the repo; new tasks the voice-agent
generated landed there too; bridges/Sutando.app couldn't see them.

**Pattern (matches #821 / #836 / #842):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution, single source of truth. CALL_RESULTS_DIR
becomes `join(WORKSPACE_DIR, 'results', 'calls')` — same workspace, just
the subpath.

**Doc-comment cleanup:**

- Renamed env-doc at line 19 from `WORKSPACE_DIR` (legacy) to
  `SUTANDO_WORKSPACE` (canonical) with a pointer to workspace_default.ts.
- Removed the stale "default to sutando/ so Claude Code subprocess picks
  up CLAUDE.md automatically" comment at line 96. Voice-agent doesn't
  spawn Claude Code anymore (`grep -n claude src/voice-agent.ts` is
  empty) — task-bridge handles that via the file pipeline. The dual-use
  rationale that conflated repo + workspace into one var is obsolete.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 15+/-4.
- All 15 existing call sites (lines 98, 142, 334, 366, 515, 547, 551,
  553, 799, 985, 1110, 1111, 1113, 1299, 1302, 1387) keep their
  `WORKSPACE_DIR` reference — they automatically pick up the new
  resolution. No site-by-site edits required.

**Operational note:**

This change takes effect on the next voice-agent restart. Currently-running
voice-agent processes keep their old paths until restart. No mid-flight
disruption.
qingyun-wu added a commit that referenced this pull request May 18, 2026
… cwd (#839)

Round-3 audit followup — `personalPath()` and `sharedPersonalPath()` both
defaulted the optional `workspace` parameter to `process.cwd()` when the
caller didn't pass one. That's the same cwd-as-workspace antipattern the
rest of the audit family is fixing: it produces the right path only when
the caller's cwd happens to be the workspace dir.

This PR changes the default to `resolveWorkspace()` (the canonical
helper introduced in #821). Callers that DO pass a workspace are
unchanged.

**Why this is the last util_paths.ts piece of the audit:**

Most callers in src/ already pass `WORKSPACE_DIR` explicitly after the
#842 / #843 migration:

- src/voice-agent.ts:345 — passes WORKSPACE_DIR (resolveWorkspace post-#843)
- src/inline-tools.ts:764 (post-#842) — passes WORKSPACE_DIR (resolveWorkspace)

What this fix catches is the still-non-passing callers:

- src/voice-agent.ts:556, 583 — `personalPath('stand-identity.json')`
  with no workspace arg. Falls through to the default.

Plus any future caller that forgets to pass workspace — the default is
now sensible.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 8+/-3.
@sonichi
sonichi merged commit 12ac8fd into main May 18, 2026
2 checks passed
@sonichi
sonichi deleted the fix/ts-workspace-audit branch May 18, 2026 19:33
qingyun-wu added a commit that referenced this pull request May 18, 2026
…owup)

Migrates voice-agent.ts's `WORKSPACE_DIR` + `CALL_RESULTS_DIR` constants to
`resolveWorkspace()` (the canonical TS helper introduced in #821). Two
const definitions cover ~15 call sites — all of which use the path for
per-user runtime state (tasks/, results/, state/, conversation.log,
briefings, audio, dynamic-content, PIDFILE, etc.).

**Why this matters:**

On hosts with `SUTANDO_WORKSPACE` set (e.g. this machine, where
`SUTANDO_WORKSPACE=/Users/qingyunwu/Documents/github/sutando-workspace-001`),
the pre-fix behavior was:

1. `WORKSPACE_DIR = process.env.WORKSPACE_DIR || <repo-path-via-import.meta.url>`
   - `WORKSPACE_DIR` env var was the *legacy* name (now `SUTANDO_WORKSPACE`).
   - Fallback resolved to the repo checkout, so voice-agent wrote tasks,
     results, conversation.log, briefings, the PIDFILE, audio dirs, etc.
     to the **repo**, while Sutando.app (post-#837), Python bridges
     (post-#762), and #842 inline-tools (just landed) all wrote/read
     from the **workspace**.

2. `CALL_RESULTS_DIR = <repo>/results/calls` — phone-call result records
   landed under the repo too.

Net effect: split-brain on `SUTANDO_WORKSPACE`-enabled hosts. Phone-call
`latest-result.json` lived under the repo; new tasks the voice-agent
generated landed there too; bridges/Sutando.app couldn't see them.

**Pattern (matches #821 / #836 / #842):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution, single source of truth. CALL_RESULTS_DIR
becomes `join(WORKSPACE_DIR, 'results', 'calls')` — same workspace, just
the subpath.

**Doc-comment cleanup:**

- Renamed env-doc at line 19 from `WORKSPACE_DIR` (legacy) to
  `SUTANDO_WORKSPACE` (canonical) with a pointer to workspace_default.ts.
- Removed the stale "default to sutando/ so Claude Code subprocess picks
  up CLAUDE.md automatically" comment at line 96. Voice-agent doesn't
  spawn Claude Code anymore (`grep -n claude src/voice-agent.ts` is
  empty) — task-bridge handles that via the file pipeline. The dual-use
  rationale that conflated repo + workspace into one var is obsolete.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 15+/-4.
- All 15 existing call sites (lines 98, 142, 334, 366, 515, 547, 551,
  553, 799, 985, 1110, 1111, 1113, 1299, 1302, 1387) keep their
  `WORKSPACE_DIR` reference — they automatically pick up the new
  resolution. No site-by-site edits required.

**Operational note:**

This change takes effect on the next voice-agent restart. Currently-running
voice-agent processes keep their old paths until restart. No mid-flight
disruption.
qingyun-wu added a commit that referenced this pull request May 18, 2026
… cwd (#839)

Round-3 audit followup — `personalPath()` and `sharedPersonalPath()` both
defaulted the optional `workspace` parameter to `process.cwd()` when the
caller didn't pass one. That's the same cwd-as-workspace antipattern the
rest of the audit family is fixing: it produces the right path only when
the caller's cwd happens to be the workspace dir.

This PR changes the default to `resolveWorkspace()` (the canonical
helper introduced in #821). Callers that DO pass a workspace are
unchanged.

**Why this is the last util_paths.ts piece of the audit:**

Most callers in src/ already pass `WORKSPACE_DIR` explicitly after the
#842 / #843 migration:

- src/voice-agent.ts:345 — passes WORKSPACE_DIR (resolveWorkspace post-#843)
- src/inline-tools.ts:764 (post-#842) — passes WORKSPACE_DIR (resolveWorkspace)

What this fix catches is the still-non-passing callers:

- src/voice-agent.ts:556, 583 — `personalPath('stand-identity.json')`
  with no workspace arg. Falls through to the default.

Plus any future caller that forgets to pass workspace — the default is
now sensible.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 8+/-3.
sonichi pushed a commit that referenced this pull request May 18, 2026
…owup) (#843)

Migrates voice-agent.ts's `WORKSPACE_DIR` + `CALL_RESULTS_DIR` constants to
`resolveWorkspace()` (the canonical TS helper introduced in #821). Two
const definitions cover ~15 call sites — all of which use the path for
per-user runtime state (tasks/, results/, state/, conversation.log,
briefings, audio, dynamic-content, PIDFILE, etc.).

**Why this matters:**

On hosts with `SUTANDO_WORKSPACE` set (e.g. this machine, where
`SUTANDO_WORKSPACE=/Users/qingyunwu/Documents/github/sutando-workspace-001`),
the pre-fix behavior was:

1. `WORKSPACE_DIR = process.env.WORKSPACE_DIR || <repo-path-via-import.meta.url>`
   - `WORKSPACE_DIR` env var was the *legacy* name (now `SUTANDO_WORKSPACE`).
   - Fallback resolved to the repo checkout, so voice-agent wrote tasks,
     results, conversation.log, briefings, the PIDFILE, audio dirs, etc.
     to the **repo**, while Sutando.app (post-#837), Python bridges
     (post-#762), and #842 inline-tools (just landed) all wrote/read
     from the **workspace**.

2. `CALL_RESULTS_DIR = <repo>/results/calls` — phone-call result records
   landed under the repo too.

Net effect: split-brain on `SUTANDO_WORKSPACE`-enabled hosts. Phone-call
`latest-result.json` lived under the repo; new tasks the voice-agent
generated landed there too; bridges/Sutando.app couldn't see them.

**Pattern (matches #821 / #836 / #842):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution, single source of truth. CALL_RESULTS_DIR
becomes `join(WORKSPACE_DIR, 'results', 'calls')` — same workspace, just
the subpath.

**Doc-comment cleanup:**

- Renamed env-doc at line 19 from `WORKSPACE_DIR` (legacy) to
  `SUTANDO_WORKSPACE` (canonical) with a pointer to workspace_default.ts.
- Removed the stale "default to sutando/ so Claude Code subprocess picks
  up CLAUDE.md automatically" comment at line 96. Voice-agent doesn't
  spawn Claude Code anymore (`grep -n claude src/voice-agent.ts` is
  empty) — task-bridge handles that via the file pipeline. The dual-use
  rationale that conflated repo + workspace into one var is obsolete.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 15+/-4.
- All 15 existing call sites (lines 98, 142, 334, 366, 515, 547, 551,
  553, 799, 985, 1110, 1111, 1113, 1299, 1302, 1387) keep their
  `WORKSPACE_DIR` reference — they automatically pick up the new
  resolution. No site-by-site edits required.

**Operational note:**

This change takes effect on the next voice-agent restart. Currently-running
voice-agent processes keep their old paths until restart. No mid-flight
disruption.
sonichi pushed a commit that referenced this pull request May 18, 2026
… cwd (#839) (#844)

Round-3 audit followup — `personalPath()` and `sharedPersonalPath()` both
defaulted the optional `workspace` parameter to `process.cwd()` when the
caller didn't pass one. That's the same cwd-as-workspace antipattern the
rest of the audit family is fixing: it produces the right path only when
the caller's cwd happens to be the workspace dir.

This PR changes the default to `resolveWorkspace()` (the canonical
helper introduced in #821). Callers that DO pass a workspace are
unchanged.

**Why this is the last util_paths.ts piece of the audit:**

Most callers in src/ already pass `WORKSPACE_DIR` explicitly after the
#842 / #843 migration:

- src/voice-agent.ts:345 — passes WORKSPACE_DIR (resolveWorkspace post-#843)
- src/inline-tools.ts:764 (post-#842) — passes WORKSPACE_DIR (resolveWorkspace)

What this fix catches is the still-non-passing callers:

- src/voice-agent.ts:556, 583 — `personalPath('stand-identity.json')`
  with no workspace arg. Falls through to the default.

Plus any future caller that forgets to pass workspace — the default is
now sensible.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 8+/-3.
liususan091219 pushed a commit that referenced this pull request May 18, 2026
…loses #840 properly)

Two stacked issues fixed in one shot.

**Production bug:** Python writers migrated core-status.json to
$SUTANDO_WORKSPACE in #836 (per CLAUDE.md workspace contract), but TS
readers in `src/web-client.ts:readCoreStatus` and `src/inline-tools.ts:
getCoreStatusTool` still read it from REPO_ROOT via `import.meta.url`-
relative paths. Same silent-divergence shape as #821/#842/#843 fixed for
other paths — voice-agent reading "core idle" while Python actually wrote
"core working" elsewhere. Fix: both TS readers now `join(WORKSPACE_DIR,
'core-status.json')`.

**Test flake (#840):** `tests/agent-state-endpoint.test.ts` and `tests/
get-core-status-tool.test.ts` both wrote to `<REPO_ROOT>/core-status.json`
in their fixtures. node:test runs files in parallel by default; the
get-core-status-tool test writes `status:'running'` as one of its cases
right when agent-state-endpoint is asserting `state:'listening'`. Race
manifested as `expected listening, got working` on whichever subtest
happened to call effectiveAgentState() during the foreign 'running' write.

Real fix is test isolation: each test process creates its own
`mkdtempSync` workspace dir and exports `SUTANDO_WORKSPACE=$TEMP_DIR`.
The spawned web-client inherits the env; the in-process tool import reads
the same temp dir. No more shared state, no more race.

PR #841's `beforeEach` reset stayed (still useful for tool-track
sequencing within the single describe block), but #840 wasn't fully closed
by that — this PR closes it for real.

Verified:
- agent-state-endpoint.test.ts: 9/9 pass
- get-core-status-tool.test.ts: 6/6 pass
- Running both together (the failure mode): 15/15 pass
- Full TS suite: 173/173 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 18, 2026
…loses #840 properly) (#849)

Two stacked issues fixed in one shot.

**Production bug:** Python writers migrated core-status.json to
$SUTANDO_WORKSPACE in #836 (per CLAUDE.md workspace contract), but TS
readers in `src/web-client.ts:readCoreStatus` and `src/inline-tools.ts:
getCoreStatusTool` still read it from REPO_ROOT via `import.meta.url`-
relative paths. Same silent-divergence shape as #821/#842/#843 fixed for
other paths — voice-agent reading "core idle" while Python actually wrote
"core working" elsewhere. Fix: both TS readers now `join(WORKSPACE_DIR,
'core-status.json')`.

**Test flake (#840):** `tests/agent-state-endpoint.test.ts` and `tests/
get-core-status-tool.test.ts` both wrote to `<REPO_ROOT>/core-status.json`
in their fixtures. node:test runs files in parallel by default; the
get-core-status-tool test writes `status:'running'` as one of its cases
right when agent-state-endpoint is asserting `state:'listening'`. Race
manifested as `expected listening, got working` on whichever subtest
happened to call effectiveAgentState() during the foreign 'running' write.

Real fix is test isolation: each test process creates its own
`mkdtempSync` workspace dir and exports `SUTANDO_WORKSPACE=$TEMP_DIR`.
The spawned web-client inherits the env; the in-process tool import reads
the same temp dir. No more shared state, no more race.

PR #841's `beforeEach` reset stayed (still useful for tool-track
sequencing within the single describe block), but #840 wasn't fully closed
by that — this PR closes it for real.

Verified:
- agent-state-endpoint.test.ts: 9/9 pass
- get-core-status-tool.test.ts: 6/6 pass
- Running both together (the failure mode): 15/15 pass
- Full TS suite: 173/173 pass

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request May 18, 2026
Followup to #849 — same workspace-contract fix for `voice-state.json`
that #849 just landed for `core-status.json`. Issue #853 filed
explicitly to track this.

**Sites migrated:**

- `src/voice-agent.ts:853` (writer) — was `writeFileSync('voice-state.json', ...)`
  (cwd-relative; effectively REPO_ROOT when launched from there).
  Now `writeFileSync(join(WORKSPACE_DIR, 'voice-state.json'), ...)`.
  `WORKSPACE_DIR` is already in scope post-#843.
- `src/web-client.ts:3137` (reader) — was
  `new URL('../voice-state.json', import.meta.url)` (REPO_ROOT-relative).
  Now `join(WORKSPACE_DIR, 'voice-state.json')`. `WORKSPACE_DIR` is
  already in scope post-#821.

**Test isolation update (mirrors #849's pattern):**

- `tests/agent-state-endpoint.test.ts` — `VOICE_STATE_PATH` was joined
  against REPO_ROOT (`dirname(fileURLToPath(import.meta.url))`). Now
  joined against the per-process `TEMP_WORKSPACE` introduced in #849.
  The 3 subtests at lines 192/211/230 that write/unlink the file pick
  up the new path automatically.
- Dropped the `savedVoiceState` stash/restore — TEMP_WORKSPACE is fresh
  per test run + `rmSync`'d on teardown, so no prod-file leak is
  possible.
- Removed now-unused imports: `readFileSync`, `existsSync`, `dirname`,
  `fileURLToPath`. Net: 32+/-23 in the test file.

**Why this bug bites:**

Pre-fix, voice-agent's writer used `writeFileSync('voice-state.json', ...)`
— a cwd-relative path. On hosts where the bridge / voice-agent was
launched from the repo, cwd ≈ REPO_ROOT and both sides aligned. But:

- On `$SUTANDO_WORKSPACE`-set hosts, the writer kept writing to repo
  while the reader (post-#849, post-#842) read from $SUTANDO_WORKSPACE.
- On any host where the launcher's cwd drifted from REPO_ROOT (IDE
  spawn, `npx tsx` from elsewhere), the writer landed in an
  unexpected location.

`dm-result.py`'s voiceConnected query goes through web-client's
`/sse-status` → `readVoiceState()` → now uniformly resolves through
WORKSPACE_DIR. Same path for the writer. Split-brain closed.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 3 files, 29 insertions / 23 deletions.

**Audit family — TS side**: this is the last known core-state file
left at REPO_ROOT. After this lands, all workspace runtime data
flows through `$SUTANDO_WORKSPACE` consistently across Python +
TS + Swift writers and readers.
sonichi added a commit that referenced this pull request May 18, 2026
…) (#855)

Followup to #849 — same workspace-contract fix for `voice-state.json`
that #849 just landed for `core-status.json`. Issue #853 filed
explicitly to track this.

**Sites migrated:**

- `src/voice-agent.ts:853` (writer) — was `writeFileSync('voice-state.json', ...)`
  (cwd-relative; effectively REPO_ROOT when launched from there).
  Now `writeFileSync(join(WORKSPACE_DIR, 'voice-state.json'), ...)`.
  `WORKSPACE_DIR` is already in scope post-#843.
- `src/web-client.ts:3137` (reader) — was
  `new URL('../voice-state.json', import.meta.url)` (REPO_ROOT-relative).
  Now `join(WORKSPACE_DIR, 'voice-state.json')`. `WORKSPACE_DIR` is
  already in scope post-#821.

**Test isolation update (mirrors #849's pattern):**

- `tests/agent-state-endpoint.test.ts` — `VOICE_STATE_PATH` was joined
  against REPO_ROOT (`dirname(fileURLToPath(import.meta.url))`). Now
  joined against the per-process `TEMP_WORKSPACE` introduced in #849.
  The 3 subtests at lines 192/211/230 that write/unlink the file pick
  up the new path automatically.
- Dropped the `savedVoiceState` stash/restore — TEMP_WORKSPACE is fresh
  per test run + `rmSync`'d on teardown, so no prod-file leak is
  possible.
- Removed now-unused imports: `readFileSync`, `existsSync`, `dirname`,
  `fileURLToPath`. Net: 32+/-23 in the test file.

**Why this bug bites:**

Pre-fix, voice-agent's writer used `writeFileSync('voice-state.json', ...)`
— a cwd-relative path. On hosts where the bridge / voice-agent was
launched from the repo, cwd ≈ REPO_ROOT and both sides aligned. But:

- On `$SUTANDO_WORKSPACE`-set hosts, the writer kept writing to repo
  while the reader (post-#849, post-#842) read from $SUTANDO_WORKSPACE.
- On any host where the launcher's cwd drifted from REPO_ROOT (IDE
  spawn, `npx tsx` from elsewhere), the writer landed in an
  unexpected location.

`dm-result.py`'s voiceConnected query goes through web-client's
`/sse-status` → `readVoiceState()` → now uniformly resolves through
WORKSPACE_DIR. Same path for the writer. Split-brain closed.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 3 files, 29 insertions / 23 deletions.

**Audit family — TS side**: this is the last known core-state file
left at REPO_ROOT. After this lands, all workspace runtime data
flows through `$SUTANDO_WORKSPACE` consistently across Python +
TS + Swift writers and readers.

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
qingyun-wu pushed a commit that referenced this pull request May 21, 2026
save_note resolved NOTES_DIR against the workspace (correct since #842),
but never ensured the notes/ subdir exists. On a fresh install — or any
workspace without a prior note — the first save_note failed with ENOENT
and surfaced to the user as a generic "issue saving the file".

Add mkdirSync(NOTES_DIR, { recursive: true }) before writeFileSync so the
first save always succeeds.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…ols sites (issue sonichi#839) (sonichi#842)

Round-3 audit followup to sonichi#836/sonichi#833/sonichi#838 — first slice of the TS audit
flagged in sonichi#839. Five bug sites across two files, all hitting the same
class: code paths resolving workspace data against `process.env.WORKSPACE_DIR`
(wrong env var name) or `process.cwd()` instead of `resolveWorkspace()`.

**Sites fixed:**

- `src/cartesia-tts.ts:29` (`getWorkspace`) — TTS audio output dir
  (`results/audio/<category>/<label>-<ts>.wav`). Pre-fix: `WORKSPACE_DIR`
  env (legacy name, not set) → `process.cwd()` fallback → audio written
  to whatever cwd the launcher had. Now resolves to `$SUTANDO_WORKSPACE`.

- `src/inline-tools.ts:471, 472` (`cancel_task` tool) — `tasksDir` and
  `resultsDir` for the cancel-task lookup. The tool listed tasks from
  `<cwd>/tasks/` and wrote cancel results to `<cwd>/results/`. On hosts
  with `SUTANDO_WORKSPACE` set, the bridge writes tasks to the workspace
  but cancel_task was looking in the repo (or whatever cwd) — so voice-
  initiated cancels would never find an in-flight task and would silently
  write a stranded CANCEL_INSTRUCTION file.

- `src/inline-tools.ts:775` (`show_view` tool) — `dynamic-content.json`
  for the menu-bar app's view toggle. Pre-fix the inline tool wrote to
  `<cwd>/dynamic-content.json` while Sutando.app (post-sonichi#837) reads from
  `$SUTANDO_WORKSPACE/dynamic-content.json`. Split-brain: voice toggle
  fired but the app never saw it on hosts with `SUTANDO_WORKSPACE`.

- `src/inline-tools.ts:873` (`recent_context` tool) — voice-agent state
  context. Pre-fix the read path looked at `<cwd>/state/voice-session-context.json`
  but the writer (core via CLAUDE.md "Voice session context" section)
  writes to `$SUTANDO_WORKSPACE/state/`. Voice-agent's deictic-pronoun
  recovery ("what was the post?", "what's pending?") would return
  `{note: "no context recorded yet"}` even when the writer had populated
  the file.

- `src/inline-tools.ts:772` (`NOTES_DIR`) — `sharedPersonalPath('notes',
  process.cwd())` passes cwd as the workspace argument to the helper.
  When `SUTANDO_PRIVATE_DIR` isn't set, the helper falls through to
  `join(workspace, 'notes')` which becomes `<cwd>/notes/` instead of
  `$SUTANDO_WORKSPACE/notes/`. read_note / search_notes silently look
  in the wrong place.

**Pattern (matches sonichi#821):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution mirrors the Python `WORKSPACE_DIR = resolve_workspace()`
canonical pattern. Single source of truth per module.

**Not addressed in this PR (still in sonichi#839's backlog):**

- `src/voice-agent.ts:97, 103` — `WORKSPACE_DIR`/`CALL_RESULTS_DIR`. The
  highest-traffic sites; defer to a focused review.
- `src/util_paths.ts:26, 54` — the fallback inside the helper itself.
  Cleaner to fix every caller first, then either delete the fallback or
  switch it to `resolveWorkspace()` so the helper has a sensible default.
- `src/inline-tools.ts:957, 1021` — `join(process.cwd(), 'skills')`.
  These are CODE-adjacent paths (skills ship with the code), so they
  need REPO_ROOT semantics, not WORKSPACE_DIR. Latent today because the
  launcher always cwd's to the repo, but worth a separate PR.
- `src/mentra-bridge.ts:88` — file is currently untracked (WIP).

**Verified:**

- `npx tsc --noEmit` ✓ clean across the whole tree.
- Diff is targeted: 2 files, 22+/-8.

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
… followup) (sonichi#843)

Migrates voice-agent.ts's `WORKSPACE_DIR` + `CALL_RESULTS_DIR` constants to
`resolveWorkspace()` (the canonical TS helper introduced in sonichi#821). Two
const definitions cover ~15 call sites — all of which use the path for
per-user runtime state (tasks/, results/, state/, conversation.log,
briefings, audio, dynamic-content, PIDFILE, etc.).

**Why this matters:**

On hosts with `SUTANDO_WORKSPACE` set (e.g. this machine, where
`SUTANDO_WORKSPACE=/Users/qingyunwu/Documents/github/sutando-workspace-001`),
the pre-fix behavior was:

1. `WORKSPACE_DIR = process.env.WORKSPACE_DIR || <repo-path-via-import.meta.url>`
   - `WORKSPACE_DIR` env var was the *legacy* name (now `SUTANDO_WORKSPACE`).
   - Fallback resolved to the repo checkout, so voice-agent wrote tasks,
     results, conversation.log, briefings, the PIDFILE, audio dirs, etc.
     to the **repo**, while Sutando.app (post-sonichi#837), Python bridges
     (post-sonichi#762), and sonichi#842 inline-tools (just landed) all wrote/read
     from the **workspace**.

2. `CALL_RESULTS_DIR = <repo>/results/calls` — phone-call result records
   landed under the repo too.

Net effect: split-brain on `SUTANDO_WORKSPACE`-enabled hosts. Phone-call
`latest-result.json` lived under the repo; new tasks the voice-agent
generated landed there too; bridges/Sutando.app couldn't see them.

**Pattern (matches sonichi#821 / sonichi#836 / sonichi#842):**

```ts
import { resolveWorkspace } from './workspace_default.js';
const WORKSPACE_DIR = resolveWorkspace();
```

Module-init resolution, single source of truth. CALL_RESULTS_DIR
becomes `join(WORKSPACE_DIR, 'results', 'calls')` — same workspace, just
the subpath.

**Doc-comment cleanup:**

- Renamed env-doc at line 19 from `WORKSPACE_DIR` (legacy) to
  `SUTANDO_WORKSPACE` (canonical) with a pointer to workspace_default.ts.
- Removed the stale "default to sutando/ so Claude Code subprocess picks
  up CLAUDE.md automatically" comment at line 96. Voice-agent doesn't
  spawn Claude Code anymore (`grep -n claude src/voice-agent.ts` is
  empty) — task-bridge handles that via the file pipeline. The dual-use
  rationale that conflated repo + workspace into one var is obsolete.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 15+/-4.
- All 15 existing call sites (lines 98, 142, 334, 366, 515, 547, 551,
  553, 799, 985, 1110, 1111, 1113, 1299, 1302, 1387) keep their
  `WORKSPACE_DIR` reference — they automatically pick up the new
  resolution. No site-by-site edits required.

**Operational note:**

This change takes effect on the next voice-agent restart. Currently-running
voice-agent processes keep their old paths until restart. No mid-flight
disruption.
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
… cwd (sonichi#839) (sonichi#844)

Round-3 audit followup — `personalPath()` and `sharedPersonalPath()` both
defaulted the optional `workspace` parameter to `process.cwd()` when the
caller didn't pass one. That's the same cwd-as-workspace antipattern the
rest of the audit family is fixing: it produces the right path only when
the caller's cwd happens to be the workspace dir.

This PR changes the default to `resolveWorkspace()` (the canonical
helper introduced in sonichi#821). Callers that DO pass a workspace are
unchanged.

**Why this is the last util_paths.ts piece of the audit:**

Most callers in src/ already pass `WORKSPACE_DIR` explicitly after the
sonichi#842 / sonichi#843 migration:

- src/voice-agent.ts:345 — passes WORKSPACE_DIR (resolveWorkspace post-sonichi#843)
- src/inline-tools.ts:764 (post-sonichi#842) — passes WORKSPACE_DIR (resolveWorkspace)

What this fix catches is the still-non-passing callers:

- src/voice-agent.ts:556, 583 — `personalPath('stand-identity.json')`
  with no workspace arg. Falls through to the default.

Plus any future caller that forgets to pass workspace — the default is
now sensible.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 1 file, 8+/-3.
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…loses sonichi#840 properly) (sonichi#849)

Two stacked issues fixed in one shot.

**Production bug:** Python writers migrated core-status.json to
$SUTANDO_WORKSPACE in sonichi#836 (per CLAUDE.md workspace contract), but TS
readers in `src/web-client.ts:readCoreStatus` and `src/inline-tools.ts:
getCoreStatusTool` still read it from REPO_ROOT via `import.meta.url`-
relative paths. Same silent-divergence shape as sonichi#821/sonichi#842/sonichi#843 fixed for
other paths — voice-agent reading "core idle" while Python actually wrote
"core working" elsewhere. Fix: both TS readers now `join(WORKSPACE_DIR,
'core-status.json')`.

**Test flake (sonichi#840):** `tests/agent-state-endpoint.test.ts` and `tests/
get-core-status-tool.test.ts` both wrote to `<REPO_ROOT>/core-status.json`
in their fixtures. node:test runs files in parallel by default; the
get-core-status-tool test writes `status:'running'` as one of its cases
right when agent-state-endpoint is asserting `state:'listening'`. Race
manifested as `expected listening, got working` on whichever subtest
happened to call effectiveAgentState() during the foreign 'running' write.

Real fix is test isolation: each test process creates its own
`mkdtempSync` workspace dir and exports `SUTANDO_WORKSPACE=$TEMP_DIR`.
The spawned web-client inherits the env; the in-process tool import reads
the same temp dir. No more shared state, no more race.

PR sonichi#841's `beforeEach` reset stayed (still useful for tool-track
sequencing within the single describe block), but sonichi#840 wasn't fully closed
by that — this PR closes it for real.

Verified:
- agent-state-endpoint.test.ts: 9/9 pass
- get-core-status-tool.test.ts: 6/6 pass
- Running both together (the failure mode): 15/15 pass
- Full TS suite: 173/173 pass

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…nichi#853) (sonichi#855)

Followup to sonichi#849 — same workspace-contract fix for `voice-state.json`
that sonichi#849 just landed for `core-status.json`. Issue sonichi#853 filed
explicitly to track this.

**Sites migrated:**

- `src/voice-agent.ts:853` (writer) — was `writeFileSync('voice-state.json', ...)`
  (cwd-relative; effectively REPO_ROOT when launched from there).
  Now `writeFileSync(join(WORKSPACE_DIR, 'voice-state.json'), ...)`.
  `WORKSPACE_DIR` is already in scope post-sonichi#843.
- `src/web-client.ts:3137` (reader) — was
  `new URL('../voice-state.json', import.meta.url)` (REPO_ROOT-relative).
  Now `join(WORKSPACE_DIR, 'voice-state.json')`. `WORKSPACE_DIR` is
  already in scope post-sonichi#821.

**Test isolation update (mirrors sonichi#849's pattern):**

- `tests/agent-state-endpoint.test.ts` — `VOICE_STATE_PATH` was joined
  against REPO_ROOT (`dirname(fileURLToPath(import.meta.url))`). Now
  joined against the per-process `TEMP_WORKSPACE` introduced in sonichi#849.
  The 3 subtests at lines 192/211/230 that write/unlink the file pick
  up the new path automatically.
- Dropped the `savedVoiceState` stash/restore — TEMP_WORKSPACE is fresh
  per test run + `rmSync`'d on teardown, so no prod-file leak is
  possible.
- Removed now-unused imports: `readFileSync`, `existsSync`, `dirname`,
  `fileURLToPath`. Net: 32+/-23 in the test file.

**Why this bug bites:**

Pre-fix, voice-agent's writer used `writeFileSync('voice-state.json', ...)`
— a cwd-relative path. On hosts where the bridge / voice-agent was
launched from the repo, cwd ≈ REPO_ROOT and both sides aligned. But:

- On `$SUTANDO_WORKSPACE`-set hosts, the writer kept writing to repo
  while the reader (post-sonichi#849, post-sonichi#842) read from $SUTANDO_WORKSPACE.
- On any host where the launcher's cwd drifted from REPO_ROOT (IDE
  spawn, `npx tsx` from elsewhere), the writer landed in an
  unexpected location.

`dm-result.py`'s voiceConnected query goes through web-client's
`/sse-status` → `readVoiceState()` → now uniformly resolves through
WORKSPACE_DIR. Same path for the writer. Split-brain closed.

**Verified:**

- `npx tsc --noEmit` ✓ clean.
- Diff is targeted: 3 files, 29 insertions / 23 deletions.

**Audit family — TS side**: this is the last known core-state file
left at REPO_ROOT. After this lands, all workspace runtime data
flows through `$SUTANDO_WORKSPACE` consistently across Python +
TS + Swift writers and readers.

Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
save_note resolved NOTES_DIR against the workspace (correct since sonichi#842),
but never ensured the notes/ subdir exists. On a fresh install — or any
workspace without a prior note — the first save_note failed with ENOENT
and surfaced to the user as a generic "issue saving the file".

Add mkdirSync(NOTES_DIR, { recursive: true }) before writeFileSync so the
first save always succeeds.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants