Repository navigation
feat(skills): inline-tool loader scans $SUTANDO_WORKSPACE/skills/ - #1081
Conversation
Adds the workspace skills dir to the scan list in both loadSkillManifestTools() and loadCoreDocumentedSkills(), alongside the existing repo skills/ and $SUTANDO_MEMORY_DIR/skills/. Lets users park personal/per-machine inline-tool skills under their workspace without forking the repo and without using the memory-sync path. Order: repo → workspace → memory-dir (last-write-wins for same-name skills, matching the prior convention). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
LGTM — clean and well-scoped.
Verified:
- `WORKSPACE_DIR = resolveWorkspace()` (inline-tools.ts:21) is the canonical resolver and already used file-wide, so the new `join(WORKSPACE_DIR, 'skills')` entry is in scope in both loaders.
- Missing-dir safe: the scan loop guards each entry with `if (!existsSync(skillsDir)) continue;`, so users without a `workspace/skills/` dir are unaffected.
- Both `loadSkillManifestTools()` and `loadCoreDocumentedSkills()` are updated symmetrically; order repo → workspace → memory-dir with last-write-wins matches the prior convention.
- Aligns with the workspace contract (per-user/per-machine logic lives outside the public repo).
One non-blocking note: if someone sets `SUTANDO_WORKSPACE` to the repo root (unusual), `workspace/skills` resolves to the same path as `repo/skills` and gets scanned twice — harmless given the `assertUniqueToolNames` dup-guard + last-write-wins, just noting it.
liususan091219
left a comment
There was a problem hiding this comment.
Cold review (Mac Studio node). Tiny +6/-7 with real ergonomic upside — workspace-local skills are now first-class without forcing a memory-dir setup. COMMENT to preserve any open author state; nothing here gates merge.
Verdict: LGTM. Symmetric application across both loader sites, safe-by-construction (existsSync guard at :994 already handles a missing workspace/skills/), and matches the established three-space precedence pattern (repo for shared infra, workspace for per-user state, memory-dir for synced personal content). One pre-existing comment claim worth tightening, no functional blocker.
Things I like (file:line):
-
src/inline-tools.ts:985, 1048— symmetric change in both loaders.loadSkillManifestToolsandloadCoreDocumentedSkillsget the same insertion at the same position in the scan list. Easy to miss the second site (the sync helper at:1048is structurally separate from the async one at:976), and the PR doesn't. Sonichi's split-sync-async comment at:1043-1047flags exactly the kind of accidental drift this PR avoids. -
:985—join(WORKSPACE_DIR, 'skills')reuses the canonical resolver, not a hand-rolled path.WORKSPACE_DIRatsrc/inline-tools.ts:21isresolveWorkspace()from./workspace_default.js(the helper landed in #821). So this scan path automatically honors$SUTANDO_WORKSPACE, expands~, and falls back to~/.sutando/workspace/— exactly the same resolution rules other components use. Nothing new to test in path semantics; the existingtests/workspace-default.test.pyalready covers them. -
:994existence guard is fail-soft.if (!existsSync(skillsDir)) continue;means a fresh install with no~/.sutando/workspace/skills/dir just skips silently — noENOENTthrown, no startup failure for users who don't have workspace skills. Same posture as the existingmemoryDirEnv()branch at:986-990. Good additive design. -
Order (
repo → workspace → memory-dir) is the right precedence. Public repo'sskills/defines the shared baseline; workspace is per-user override / addition; memory-dir is fleet-synced personal content. A user adding a personal experiment under$SUTANDO_WORKSPACE/skills/experimental/doesn't need to commit it anywhere — exactly the use case Susan hit yesterday with stranded scratch under repo-rootskills/(had to local-exclude via.git/info/exclude). -
Net -1 line + comment refresh. The PR doesn't just add the path — it rewrites the comment block (
:978-984) to describe the new three-space precedence. Future reader won't infer wrong from a stale comment.
Non-blocking observations:
-
(important) The comment's "last-write-wins" framing is misleading — both before AND after this PR. Lines
:983-984(existing) and the new PR description both claim same-name skills resolve via "last-write-wins via the dup-name guard." Actually the dup-name guard atsrc/inline-tools.ts:941-955(assertUniqueToolNames) hard-throws on duplicate tool names rather than de-duping —Gemini 3.1 Live rejects dup names at setup (1011)per its own comment. The scan-and-push loop at:1018-1024push-accumulates tools from both dirs onto the sameowner/anyCallerarrays; same-name skills would therefore produce duplicate tool names in the array, triggering the guard throw at startup, not silent shadowing. So the actual semantics are "all-or-nothing — same-name skills cause boot failure," not "last-write-wins." Pre-existing tech-debt, not a regression — but with this PR adding a third scan dir, the surface area for a name collision grew from 2 dirs to 3 dirs. Worth a follow-up to either: (a) actually dedupe by skill-name at the dir-scan layer so the last-scanned wins (matches the comment), or (b) update both comments to "first-wins; same-name across dirs is a startup error." Either is fine; the current state where the comment lies is the worst-of-both. Not in this PR's scope but flagging because the PR amplifies it. -
No tests added. The path-resolution behavior is covered by
tests/workspace-default.test.py(Python) andtests/util-paths-memory-dir.test.ts(TS) for the env var, but no test asserts that a skill at$SUTANDO_WORKSPACE/skills/<name>/actually gets loaded into the inline-tools array. A focused unit test that setsSUTANDO_WORKSPACE=/tmp/xxx, drops a stubmanifest.json + tools.tsunder/tmp/xxx/skills/probe/, runs the loader, and asserts the probe tool appears inpersonalAllToolswould catch a future regression where someone refactors the scan loop and drops the workspace branch. Not a merge blocker; worth a fast-follow if you want defense-in-depth. -
Subtle interaction with #1082 (obsidian-vault) opt-in. PR #1082 (jsun-m's other open PR) writes a vault to
$SUTANDO_WORKSPACE/obsidian-vault— a different subdir thanskills/, so no collision. But both PRs use$SUTANDO_WORKSPACEas an opt-in personal-content root in the same release window, which is the right convergence on the workspace contract perdocs/workspace-design.md. Worth merging both relatively close in time so the workspace contract presents as one coherent story rather than two separate features.
Cross-PR linkage:
- #821 introduced
resolveWorkspace()(the TS helper this PR consumes) — PR is the natural follow-up that lets workspace start carrying user-loaded code, not just runtime state. - #870 / #858 established the three-space
repo / workspace / memory-dirmodel; this PR is the final inline-tools-loader step toward "all three layers honor the same precedence pattern," same shape assync-memory.sh(per #1079) andutil_paths.py:40-50(Python). - #1082 is the first opt-in feature to write under
$SUTANDO_WORKSPACEnon-trivially; this PR enables it to also drop a skill there if it grows that surface later.
Tests note: Whole-project npx tsc --noEmit on local main (which includes this branch's content per the 33-PR-merge I did this morning) passes. No existing test references workspace-skills loading specifically, so this PR's behavior is exercised only at boot time.
Smoke I ran: Re-read both loader sites, confirmed WORKSPACE_DIR import + definition at :14, :21, verified existsSync fail-soft posture, traced the dup-name-guard semantics at :941-955 to confirm the "last-write-wins" comment is wishful (per observation 1).
Ready to merge as-is. Observation 1 (the comment / guard semantic mismatch) is pre-existing and not in scope here — the PR didn't introduce it, but it does inherit it. Fix in a follow-up.
— Lucy (Mac Studio bot)
liususan091219
left a comment
There was a problem hiding this comment.
Cold review (MacBook node). Tiny PR (+6/-7), one clean idea: insert $SUTANDO_WORKSPACE/skills/ between repo and $SUTANDO_MEMORY_DIR/skills/ in the two scan lists. WORKSPACE_DIR is already imported + resolved at src/inline-tools.ts:21 via resolveWorkspace(), so the wiring is free. Good motivating use case (voice-bubble/ doesn't belong in the public tree, and overloading memory-dir for per-machine skills was always a smell).
BLOCKING
None.
NON-BLOCKING
-
The "last-write-wins" claim should be verified against
assertUniqueToolNamesatsrc/inline-tools.ts:948. The dup-name guard in this file throws on collision — if a workspace skill exports a tool whosenamematches one already loaded from the repo, the PR's "workspace wins" docstring will mislead: voice-agent will crash at startup instead of silently overriding. The merge order indirsToScancontrols skill directory iteration, but tool-name uniqueness is enforced downstream. Either (a) confirm via test that an actual same-tool-name collision is silently shadowed (not thrown), or (b) reword the comment to "last skill directory wins for same skill name; same tool name across directories still throws." Cheap to verify with one extra entry in the test plan. -
Missing-dir handling. New users won't have
~/.sutando/workspace/skills/until they create it. The existing memory-dir branch only adds the path afterif (privateRoot)exists; the new workspace branch unconditionally pushes the path regardless of existence. If the loader (fs.readdirSyncdownstream) doesn't already swallow ENOENT, this regresses startup for everyone who hasn't opted in. Quick check the iterator handles a non-existent entry — if it does, fine; if not, gate withfs.existsSync(join(WORKSPACE_DIR, 'skills'))like the memory-dir branch does. -
Python side.
src/inline-tools.tsis the TS surface, but Sutando also hasskills/consumers on the Python side (health-check, etc.). Worth a one-line "Python loader untouched — TS-only scope, separate PR if needed" in the PR body so reviewers know not to look for a parallel patch. -
tools.ts+node_modulesresolution — already called out in the test plan as a documented limitation. Apackage.json-per-skill story would be the eventual fix, but absolutely not a blocker for this PR; it's a follow-up. Just flag it explicitly in the PR description with an issue number rather than leaving it as a TODO bullet, so the constraint is tracked.
LGTM after item (1) is either confirmed by test or by reword. Items 2–4 are nits.
— Maddy
…metadata + workspace scan 19 tests covering two merged PRs: PR sonichi#1077 — channel_name + guild_name in discord task files (12 tests): - channel_name and guild_name fields present in task_file.write_text() - channel_name appears AFTER channel_id (additive, no breaking change) - DM fallback: getattr(message.channel, "name", None) or "DM" - Guild fallback: message.guild.name if message.guild else "DM" - Newline sanitization on both fields (injection guard from qingyun review) - Both fields in same task-write block - Comment explaining human-readable purpose PR sonichi#1081 — inline-tool loader scans $SUTANDO_WORKSPACE/skills/ (7 tests): - loadSkillManifestTools() includes WORKSPACE_DIR/skills in scan paths - loadCoreDocumentedSkills() includes WORKSPACE_DIR/skills in scan paths - REPO_ROOT/skills and WORKSPACE_DIR/skills both present - Scan order: REPO_ROOT/skills < WORKSPACE_DIR/skills (repo → workspace → memory) - last-write-wins convention documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…er (sonichi#1081) Adds the workspace skills dir to the scan list in both loadSkillManifestTools() and loadCoreDocumentedSkills(), alongside the existing repo skills/ and $SUTANDO_MEMORY_DIR/skills/. Lets users park personal/per-machine inline-tool skills under their workspace without forking the repo and without using the memory-sync path. Order: repo → workspace → memory-dir (last-write-wins for same-name skills, matching the prior convention). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…ask-metadata, and outbox labels Covers: - 22 tests for delivery-idempotency sentinels + send_allowlist (sonichi#1048/sonichi#1029) - 19 tests for task-file channel/guild metadata + inline-tools workspace scan (sonichi#1077/sonichi#1081) - 15 tests for outbox_log recipient_label (sonichi#1078) and submodule/worktree detection (sonichi#1076) All 56 tests pass against current main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…metadata + workspace scan 19 tests covering two merged PRs: PR sonichi#1077 — channel_name + guild_name in discord task files (12 tests): - channel_name and guild_name fields present in task_file.write_text() - channel_name appears AFTER channel_id (additive, no breaking change) - DM fallback: getattr(message.channel, "name", None) or "DM" - Guild fallback: message.guild.name if message.guild else "DM" - Newline sanitization on both fields (injection guard from qingyun review) - Both fields in same task-write block - Comment explaining human-readable purpose PR sonichi#1081 — inline-tool loader scans $SUTANDO_WORKSPACE/skills/ (7 tests): - loadSkillManifestTools() includes WORKSPACE_DIR/skills in scan paths - loadCoreDocumentedSkills() includes WORKSPACE_DIR/skills in scan paths - REPO_ROOT/skills and WORKSPACE_DIR/skills both present - Scan order: REPO_ROOT/skills < WORKSPACE_DIR/skills (repo → workspace → memory) - last-write-wins convention documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…workspace skill scan join-trigger-mention-prefix.test.py (25): Guards the @-mention prefix that join-trigger adds to voice-session invitations — correct format, trailing whitespace, newline handling, multi-phrase edge cases. Closes sonichi#1113 test coverage gap. inline-tools-workspace-skills-scan.test.ts (3): Structural tests verifying inline-tools scans $SUTANDO_WORKSPACE/skills/ (not just repo-root skills/) for dynamically-loaded skill manifests. Guards the workspace-skills path from sonichi#1081. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ask-metadata, and outbox labels Covers: - 22 tests for delivery-idempotency sentinels + send_allowlist (sonichi#1048/sonichi#1029) - 19 tests for task-file channel/guild metadata + inline-tools workspace scan (sonichi#1077/sonichi#1081) - 15 tests for outbox_log recipient_label (sonichi#1078) and submodule/worktree detection (sonichi#1076) All 56 tests pass against current main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…metadata + workspace scan 19 tests covering two merged PRs: PR sonichi#1077 — channel_name + guild_name in discord task files (12 tests): - channel_name and guild_name fields present in task_file.write_text() - channel_name appears AFTER channel_id (additive, no breaking change) - DM fallback: getattr(message.channel, "name", None) or "DM" - Guild fallback: message.guild.name if message.guild else "DM" - Newline sanitization on both fields (injection guard from qingyun review) - Both fields in same task-write block - Comment explaining human-readable purpose PR sonichi#1081 — inline-tool loader scans $SUTANDO_WORKSPACE/skills/ (7 tests): - loadSkillManifestTools() includes WORKSPACE_DIR/skills in scan paths - loadCoreDocumentedSkills() includes WORKSPACE_DIR/skills in scan paths - REPO_ROOT/skills and WORKSPACE_DIR/skills both present - Scan order: REPO_ROOT/skills < WORKSPACE_DIR/skills (repo → workspace → memory) - last-write-wins convention documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nctions (sonichi#1081) 3 structural TS tests verify the sonichi#1081 invariants that were not included in the upstream merge commit: loadSkillManifestTools and loadCoreDocumentedSkills both include WORKSPACE_DIR/skills in dirsToScan, and the scan-order comment documents repo → workspace → private. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…workspace skill scan join-trigger-mention-prefix.test.py (25): Guards the @-mention prefix that join-trigger adds to voice-session invitations — correct format, trailing whitespace, newline handling, multi-phrase edge cases. Closes sonichi#1113 test coverage gap. inline-tools-workspace-skills-scan.test.ts (3): Structural tests verifying inline-tools scans $SUTANDO_WORKSPACE/skills/ (not just repo-root skills/) for dynamically-loaded skill manifests. Guards the workspace-skills path from sonichi#1081. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
$SUTANDO_WORKSPACE/skills/to the inline-tool loader's scan list, in bothloadSkillManifestTools()(voice-inline tools) andloadCoreDocumentedSkills()(core-documented manifests). Reposkills/is still scanned first; the existing$SUTANDO_MEMORY_DIR/skills/path is unchanged.Why
Lets users park personal or per-machine inline-tool skills under their workspace without forking the repo and without coupling to the memory-sync path. The motivating case: moving
voice-bubble/out of the repo into$SUTANDO_WORKSPACE/skills/voice-bubble/so an owner can iterate locally without touching the public tree. Memory-dir was the only escape hatch before, and it's overloaded with cross-fleet sync semantics that don't fit per-machine skills.Test plan
manifest.json+tools.tsunder$SUTANDO_WORKSPACE/skills/<name>/; log line confirms[skill-loader] loaded N tool(s) from <name> [tier=...] ($SUTANDO_WORKSPACE/skills).dirsToScanorder).tools.tsin the workspace dir resolvesnode_modules(currently requires a symlink to the repo'snode_modulesfor skills that import packages — documented for a follow-up if we want to ship a per-skillpackage.jsonstory).🤖 Generated with Claude Code