diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 625da0f27..23230cdcd 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -325,3 +325,19 @@ This disables skill discovery from `~/.claude/skills/` and `~/.agents/skills/` b ## Duplicate Handling If multiple skills share the same name, project-level skills override global skills. A warning is logged when duplicates are found. + +## Which Skill the Agent Picks + +The skill list the agent sees marks each skill with where it was found: + +| Source | Where | +|---|---| +| `workspace` | Synced from the Altimate workspace this project is linked to | +| `project` | Inside the project, such as `.claude/skills/`, `.agents/skills/` or `.altimate-code/skills/` | +| `built-in` | Ships with altimate-code | +| `personal` | Your own folders under your home directory, such as `~/.claude/skills/` | +| `other` | Anywhere else, such as a `skills.paths` entry outside the project and your home directory | + +When more than one skill fits a task, the agent is told to load the one from the highest source in that order, and to fall back to a lower source only when nothing higher covers the task. So a skill your team added to the workspace is preferred over a similar skill another team left in the repository. The label comes from the skill's location, not from anything written in the skill. + +One exception: a project skill with exactly the same name as a workspace skill replaces it, and is listed as `project`. That keeps a skill you are editing in the repository, and may later publish, from being shadowed by the workspace's older copy. diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 7804fd7a2..02e0649a5 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -54,14 +54,15 @@ Workspace features are on by default. When you are signed in to Altimate, altima With workspaces on: -- `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. +- `altimate-code link` links the current project to a workspace (or creates one), then shows what the workspace provides (its skills, knowledge, integrations and whether memory is on) and a few prompts to start with. The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. - From a shell, script or CI job, without the TUI: - `altimate-code link --workspace ` links to an existing workspace (an id is matched before a name), and `altimate-code link --create [name]` creates one (named after the repo by default) and links to it. Neither prompts. If the project is already linked to a different workspace they refuse unless `--yes` is passed. Running `--create` again when the project is already linked to a workspace of that name creates nothing, so it is safe in a devcontainer's setup command; if another workspace already has that name it refuses and names its id, unless `--allow-duplicate` is passed. Linking to the workspace the project is already linked to confirms that link on this machine, which a fresh clone needs before `workspace sync` sends its memory. - `altimate-code workspace status [--json]` shows the linked workspace and, when known, how many saved memories have not reached it and when skills last synced. - `altimate-code workspace refresh` pulls the workspace's skills (memory is loaded by the next session started in the project); `altimate-code workspace sync` sends memory the workspace has not received; `altimate-code workspace unlink [--yes]` detaches the project (`--yes` is required without a terminal). - These commands accept `--directory`. Exit codes: `0` done, `1` failed (including when the workspace service could not be reached), `2` a request to change (not signed in, a re-link or unlink without `--yes` and no terminal to confirm on, or a `sync` to a link not yet confirmed on this machine), `3` the project is not linked. With `--json`, `ok` is `true` exactly when the exit code is `0`. -- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. +- `/workspace` in the TUI opens a menu: **Get started** shows what the workspace provides and prompts to try (choosing one puts it in the prompt without sending it; after you link from the TUI, the confirmation shows the same summary and prompts), **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). +- When the linked workspace has knowledge, the agent is told to check it for best practices and team conventions before answering, without you having to mention the knowledge base. - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. - When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and (unless `--integrations local` is set) warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one. diff --git a/packages/opencode/src/altimate/skill-source.ts b/packages/opencode/src/altimate/skill-source.ts new file mode 100644 index 000000000..c313dc6ee --- /dev/null +++ b/packages/opencode/src/altimate/skill-source.ts @@ -0,0 +1,83 @@ +// altimate_change - new file +// +// Where a skill came from, shown to the model next to each listed skill. Without it a skill the +// linked workspace's members added, one the repository carries, a built-in and one in some other +// team's folder all looked the same, and with near-duplicates in the catalog the agent asked "which +// team's policy?" or explored the repo for clues instead of loading the workspace's own skill. +// The skill-routing measurement went from 4/16 to 16/16 with a source label plus one precedence rule. +// +// Decided by the skill's location, which Altimate Code chose when it found the file; nothing in the +// skill's own text can change it. Dependency-free like ./workspace/snapshot-path, which skill +// discovery also imports. +import path from "path" +import { isWithin, snapshotProjectOf } from "./workspace/snapshot-path" + +/** Highest precedence first, the order `SKILL_PRECEDENCE_RULE` states. */ +export const SKILL_SOURCES = ["workspace", "project", "built-in", "personal", "other"] as const +export type SkillSource = (typeof SKILL_SOURCES)[number] + +/** Altimate-shipped: an embedded skill, or one installed with Altimate Code. Given `home`, an installed copy + * counts only inside `/.altimate/builtin`, where it is installed; without it (skill-use telemetry, which + * has no home to hand) the `.altimate/builtin` segment anywhere is enough. */ +export function isBuiltinSkillLocation(location: string, home?: string): boolean { + const normalized = location.replace(/\\/g, "/") + if (normalized.startsWith("builtin:") || normalized === "") return true + if (/\/node_modules\/(@altimateai\/|altimate-code\/)/.test(normalized)) return true + if (home !== undefined) return path.isAbsolute(location) && isWithin(path.join(home, ".altimate", "builtin"), location) + return normalized.includes("/.altimate/builtin/") +} + +export interface SkillSourceContext { + projectRoot?: string + home: string + /** The skill file's resolved path, when it differs from `location` (a symlink). */ + real?: string + /** The project root's resolved path. */ + realProjectRoot?: string + /** The session directory and its resolved path. A project linked from a subdirectory of its repository + * keeps its snapshot there, not at the repository root. */ + directory?: string + realDirectory?: string +} + +/** + * - workspace: this project's synced workspace snapshot, at the project root or the session directory (a project + * linked from a subdirectory of its repository). Judged on both the matched and the resolved path, and only for + * this project's own snapshot: a configured path or a symlink into another project's snapshot, or out + * of this one, is "other", not the workspace this project is linked to. + * - project: inside the project — `.claude/skills`, `.agents/skills`, `.altimate-code/skills`. + * - built-in: ships with Altimate Code. + * - personal: the user's own folders under their home directory. + * - other: anything else, such as a configured path outside both. + * + * A project opened at the home directory itself would claim every personal skill, so there the + * home directory is not treated as a project. + */ +export function skillSource(location: string, ctx: SkillSourceContext): SkillSource { + if (isBuiltinSkillLocation(location, ctx.home)) return "built-in" + if (!path.isAbsolute(location)) return "other" + const real = ctx.real ?? location + const matchedProject = snapshotProjectOf(location) + const realProject = snapshotProjectOf(real) + if (matchedProject !== null || realProject !== null) { + const ours = (project: string | null, roots: (string | undefined)[]) => + project !== null && roots.some((root) => root !== undefined && path.resolve(project) === path.resolve(root)) + return ours(matchedProject, [ctx.projectRoot, ctx.directory]) && + ours(realProject, [ctx.realProjectRoot ?? ctx.projectRoot, ctx.realDirectory ?? ctx.directory]) + ? "workspace" + : "other" + } + const home = path.resolve(ctx.home) + if (ctx.projectRoot && path.resolve(ctx.projectRoot) !== home && isWithin(ctx.projectRoot, location)) return "project" + if (isWithin(home, location)) return "personal" + return "other" +} + +/** The rule that goes with the labels, stated once in the skills preamble. */ +export const SKILL_PRECEDENCE_RULE = [ + 'Each skill below carries a `source` that Altimate Code set from where it found the skill: "workspace" (added by', + 'the team in the Altimate workspace this project is linked to), "project" (in this repository), "built-in" (ships', + 'with Altimate Code), "personal" (the user\'s own folders), or "other". When more than one skill fits a task,', + "load the one from the highest source in that order, rather than a lower one with a similar purpose, and do not", + "ask the user which team's skill to use. Use a lower source only when nothing higher covers the task.", +].join("\n") diff --git a/packages/opencode/src/altimate/workspace/contents.ts b/packages/opencode/src/altimate/workspace/contents.ts index 4d15275b1..7bdfc4f8f 100644 --- a/packages/opencode/src/altimate/workspace/contents.ts +++ b/packages/opencode/src/altimate/workspace/contents.ts @@ -231,7 +231,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise i.id).sort() : null, + integrations: Array.isArray(s.integrations) ? s.integrations.map((i) => clean(i.id, 80)).sort() : null, memoryEnabled: typeof s.memory_enabled === "boolean" ? s.memory_enabled : null, knowledge, }, @@ -245,7 +245,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise { +export async function workspaceSummary(datamateId: number, now = Date.now(), waitMs = SUMMARY_WAIT_MS): Promise { const account = await currentAccount() if (account === null) return UNKNOWN const key = `${account.key}:${datamateId}` @@ -266,7 +266,7 @@ export async function workspaceSummary(datamateId: number, now = Date.now()): Pr return await Promise.race([ pending.then((f) => f.value), new Promise((done) => { - timer = setTimeout(() => done(stale), SUMMARY_WAIT_MS) + timer = setTimeout(() => done(stale), waitMs) timer.unref?.() }), ]) @@ -345,6 +345,13 @@ function renderWith(contents: WorkspaceContents, detail: SkillDetail): string { "CLI and are available in every project; they are not part of the workspace — mention them only if " + "asked, under a separate \"built-in\" heading. Do not present every installed skill as the workspace's.", ) + // Without this the agent searched the knowledge only when the user said "check the knowledge base", so the + // team's own practices lost to general advice on exactly the questions they were written for. + if (k && k.kind !== "off" && !(k.kind === "selected" && k.names?.length === 0)) + lines.push( + "For best practices, conventions or how this team does things, check this workspace's knowledge " + + "(`ask_knowledge_base`, when available) first, unprompted, and prefer it over general advice.", + ) return lines.join("\n") } @@ -361,19 +368,29 @@ export function render(contents: WorkspaceContents, cap = MAX_CONTENTS_CHARS): s return minimal.length <= cap ? minimal : "" } +/** Everything the section and the starter show, each part waited on for at most `waitMs`. A prompt step + * keeps the short default; a one-off view (the starter after a link) can afford to wait for the service. */ +export async function workspaceContents( + directory: string, + datamateId: number, + waitMs: { skills: number; summary: number } = { skills: SKILLS_WAIT_MS, summary: SUMMARY_WAIT_MS }, +): Promise { + let timer: ReturnType | undefined + const skills = Promise.race([ + workspaceSkills(directory, datamateId), + new Promise<"unknown">((done) => { + timer = setTimeout(() => done("unknown"), waitMs.skills) + timer.unref?.() + }), + ]).finally(() => timer && clearTimeout(timer)) + const [s, summary] = await Promise.all([skills, workspaceSummary(datamateId, Date.now(), waitMs.summary)]) + return { skills: s, ...summary } +} + /** The section for a bound workspace; "" on any failure, so prompt assembly never breaks. */ export async function section(directory: string, datamateId: number): Promise { try { - let timer: ReturnType | undefined - const skills = Promise.race([ - workspaceSkills(directory, datamateId), - new Promise<"unknown">((done) => { - timer = setTimeout(() => done("unknown"), SKILLS_WAIT_MS) - timer.unref?.() - }), - ]).finally(() => timer && clearTimeout(timer)) - const [s, summary] = await Promise.all([skills, workspaceSummary(datamateId)]) - return render({ skills: s, ...summary }) + return render(await workspaceContents(directory, datamateId)) } catch { return "" } diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index 5b5080204..a9b5945f7 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -328,6 +328,11 @@ const store: SyncStore = (globals[STORE_KEY] ??= { * start racing on the same project do not both stage and swap. */ const inFlight = store.inFlight +/** The sync running for `directory` now, if any, without starting one. */ +export function pendingSync(directory: string): Promise | undefined { + return inFlight.get(path.resolve(directory)) +} + /** How long a snapshot is trusted before the next turn re-checks the workspace. * * `prompt` runs per message, so syncing on every turn would put an HTTP round diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts new file mode 100644 index 000000000..753207392 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -0,0 +1,147 @@ +// altimate_change - new file +// +// The "here is what this workspace has, what next?" message shown once a project is linked: in +// `altimate-code link`, in the TUI after a link, and to the IDE extension's chat through +// `GET /altimate/workspace/starter`. Fixed in format and built from the same data as the prompt's +// "What this Altimate Workspace provides" section, so it costs no model call (none of the user's +// tokens) and reads the same every time. Anything not known is left out, never shown as "none". +import { displayWorkspaceName } from "./workspace-name" +import { workspaceContents, type WorkspaceContents } from "./contents" + +export interface Starter { + workspace: string + /** One line per thing the workspace provides, in a fixed order. */ + lines: string[] + /** The same, in one short line for a dialog title: "3 skills · 2 knowledge documents · memory on". */ + summary: string + /** Prompts the user can send as they are; at most `MAX_PROMPTS`. */ + prompts: string[] + /** The whole message as plain text, for a terminal or a chat bubble. */ + text: string +} + +const MAX_PROMPTS = 3 +const MAX_LISTED_SKILLS = 8 +const MAX_LISTED_DOCUMENTS = 5 +/** The starter is shown once, after a link the user is watching, so it can wait for the service. */ +const STARTER_WAIT_MS = { skills: 2_000, summary: 5_000 } + +function list(names: string[], max: number): string { + const shown = names.slice(0, max) + const more = names.length - shown.length + return shown.join(", ") + (more > 0 ? `, and ${more} more` : "") +} + +function skillsLine(skills: WorkspaceContents["skills"]): string | null { + if (skills === null) return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." + if (skills === "unknown") return null + if (skills.length === 0) return "Skills: none yet. Add them in the workspace and they sync here." + return `Skills (${skills.length}): ${list(skills.map((s) => s.name), MAX_LISTED_SKILLS)}` +} + +function knowledgeLine(k: WorkspaceContents["knowledge"]): string | null { + if (k === null) return null + if (k.kind === "off") return "Knowledge: off for this workspace." + if (k.kind === "all") return "Knowledge: every knowledge hub document you can read." + if (k.names === null) return `Knowledge: ${k.selected} selected document${k.selected === 1 ? "" : "s"}.` + if (k.names.length === 0) return "Knowledge: none. The documents this workspace selected no longer exist." + return `Knowledge (${k.names.length}): ${list(k.names, MAX_LISTED_DOCUMENTS)}` +} + +function integrationsLine(ids: string[] | null): string | null { + if (ids === null) return null + return ids.length === 0 ? "Integrations: none attached." : `Integrations: ${ids.join(", ")}` +} + +function memoryLine(on: boolean | null): string | null { + if (on === null) return null + return on + ? "Memory: on. What you teach Altimate Code is saved automatically and shared with your team in the CLI, the IDE and Studio." + : "Memory: off for this workspace, so saved memory stays on this machine." +} + +function hasKnowledge(k: WorkspaceContents["knowledge"]): boolean { + return k !== null && (k.kind === "all" || (k.kind === "selected" && k.names?.length !== 0)) +} + +/** Suggestions in a fixed order, each only when the workspace has what it needs. */ +function suggestions(contents: WorkspaceContents): string[] { + const out: string[] = [] + const skills = Array.isArray(contents.skills) ? contents.skills.filter((s) => !s.unreadable) : [] + if (skills.length > 0) out.push("Which of our workspace skills fit this project?") + if (hasKnowledge(contents.knowledge)) out.push("What are our best practices for this project?") + const integrations = contents.integrations ?? [] + if (integrations.some((id) => id.toLowerCase().includes("snowflake"))) + out.push("Which Snowflake queries cost us most this week?") + out.push("Explain this project and where to start.") + return out.slice(0, MAX_PROMPTS) +} + +function summaryOf(contents: WorkspaceContents): string { + const parts: string[] = [] + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}` + if (Array.isArray(contents.skills)) parts.push(plural(contents.skills.length, "skill")) + const k = contents.knowledge + if (k?.kind === "all") parts.push("all your knowledge hub documents") + else if (k?.kind === "selected") parts.push(plural(k.names?.length ?? k.selected, "knowledge document")) + else if (k?.kind === "off") parts.push("knowledge off") + if (contents.integrations !== null) + parts.push(contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`) + if (contents.memoryEnabled !== null) parts.push(contents.memoryEnabled ? "memory on" : "memory off") + return parts.join(" · ") +} + +/** Pure: the same contents always give the same message. */ +export function renderStarter(workspaceName: string, contents: WorkspaceContents): Starter { + const workspace = displayWorkspaceName(workspaceName) || "(unnamed)" + const lines = [ + skillsLine(contents.skills), + knowledgeLine(contents.knowledge), + integrationsLine(contents.integrations), + memoryLine(contents.memoryEnabled), + ].filter((line): line is string => line !== null) + const prompts = suggestions(contents) + const text = [ + `You're working in the "${workspace}" Altimate workspace.`, + "", + ...lines.map((line) => `- ${line}`), + "", + "What do you want to do? For example:", + ...prompts.map((p, i) => ` ${i + 1}. ${p}`), + ].join("\n") + return { workspace, lines, summary: summaryOf(contents), prompts, text } +} + +/** The message wrapped to `width` columns for a framed terminal note, which grows to its longest line + * rather than wrapping. A continuation keeps the indent of its list item. */ +export function wrapStarter(text: string, width: number): string { + const max = Math.max(20, width) + return text + .split("\n") + .flatMap((line) => { + if (Bun.stringWidth(line) <= max) return [line] + const lead = line.match(/^\s*/)?.[0] ?? "" + const indent = " ".repeat((line.match(/^\s*(?:- |\d+\. )?/)?.[0] ?? "").length) + const out: string[] = [] + let current = lead + for (const word of line.slice(lead.length).split(" ")) { + const next = current.trim() ? `${current} ${word}` : current + word + if (current.trim() && Bun.stringWidth(next) > max) { + out.push(current) + current = indent + word + } else current = next + } + out.push(current) + return out + }) + .join("\n") +} + +/** The starter for a linked project. `directory` is the project key the link was recorded under. */ +export async function starterFor( + directory: string, + binding: { datamateId: number; datamateName: string }, + waitMs = STARTER_WAIT_MS, +): Promise { + return renderStarter(binding.datamateName, await workspaceContents(directory, binding.datamateId, waitMs)) +} diff --git a/packages/opencode/src/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts index c955b4a9b..5cbf63c02 100644 --- a/packages/opencode/src/altimate/workspace/welcome-lines.ts +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -44,7 +44,8 @@ function modeName(name: string): string { /** The commands workspace mode registers in the palette. Kept here rather * than read from the palette so the line is stable and testable; the plugin * that registers them is the same one that renders this. */ -export const WORKSPACE_COMMANDS = "/workspace — status, refresh, sync, unlink · /skills — the workspace's skills" +// altimate_change - leads with the starter: what the workspace has, and prompts to try +export const WORKSPACE_COMMANDS = "/workspace → Get started: what it has, prompts to try · /skills — its skills" /** `snapshot` is the bound workspace's, as `currentAttachSnapshot` returns it: * undefined when no session has attached to this workspace yet. It is always diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 8c90a2e4e..dbfaa9224 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -503,6 +503,11 @@ async function runBrowserHandoff( prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) + // altimate_change - what the workspace provides, and what to try first + await printStarter(identifier.projectPath ?? directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + }) prompts.outro("Done.") } catch (err) { bindSpin.stop("Link failed.", 1) @@ -735,6 +740,11 @@ export async function createThenBindOrRebind( prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) } } + // altimate_change - what the workspace provides, and what to try first + await printStarter(identifier.projectPath ?? directory, { + datamateId: created.datamate.id, + datamateName: created.datamate.name, + }) prompts.outro("Done.") } @@ -865,6 +875,11 @@ async function bindOrRebind( prompts.log.info(seedMessage(seed)) const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) + // altimate_change - what the workspace provides, and what to try first + await printStarter(identifier.projectPath ?? directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + }) prompts.outro("Done.") } catch (err) { spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) @@ -886,6 +901,23 @@ async function bindOrRebind( } } +// altimate_change start — the workspace starter, shown once a link succeeds +/** What the linked workspace provides and a few prompts to start with. Best-effort: a link that + * succeeded is reported as such even when this cannot be shown. A terminal gets a framed note; + * a log (headless, CI) gets plain lines. */ +async function printStarter(directory: string, binding: { datamateId: number; datamateName: string }): Promise { + try { + const { starterFor, wrapStarter } = await import("@/altimate/workspace/starter") + const starter = await starterFor(directory, binding) + // The note's frame and gutter take about 8 columns; 100 keeps it readable on a wide terminal. + if (process.stdout.isTTY) prompts.note(wrapStarter(starter.text, Math.min(100, (process.stdout.columns ?? 80) - 8)), "Get started") + else UI.println(starter.text) + } catch { + // nothing to add; the link itself is done + } +} +// altimate_change end + /** What `link` says about this machine's saved memory after the bind. A seed that left * blocks behind used to print the same line as one that stored everything. */ export function seedMessage(seed: SeedOutcome | null): string { @@ -1169,6 +1201,7 @@ function linkHeadlessDeps(directory: string): LinkHeadlessDeps { // comes back after the write, when only the memory seed was skipped (seedMessage says so). if (!isApprovedRow(key, account, existing.datamate.id, linkedAt)) return false UI.println(seedMessage(seed)) + await printStarter(key, { datamateId: existing.datamate.id, datamateName: existing.datamate.name }) return true }, listDatamates: (actAs) => WorkspaceApi.listDatamates(actAs), diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index d3b95cbee..62b465fd7 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -17,6 +17,7 @@ import TraceViewer from "./trace-viewer" import Workspace from "./workspace" import WorkspaceSidebar from "./workspace-sidebar" import LearnNudge from "./learn-nudge" +import WorkspaceWelcome from "./workspace-welcome" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -31,9 +32,9 @@ export function altimateTuiPlugins(_flags: Pick { void refresh() const timer = setInterval(() => void refresh(), POLL_MS) - onCleanup(() => clearInterval(timer)) + // A link or unlink made in this process shows at once, not up to BINDING_REFRESH_MS later: the box sits + // behind the link dialog and otherwise kept saying "not linked" under the confirmation. + const unsubscribe = onBindingChanged(() => { + resolvedAt = null + void refresh() + }) + onCleanup(() => { + clearInterval(timer) + unsubscribe() + }) }) - const current = () => state().lines + return state().lines} theme={theme} /> +} + +/** The lines, under a root that is always there. OpenTUI's `Slot` shows its fallback for a plugin view + * whose first render has no output, and renders the view again when that output changes. With a `` + * root still waiting on the binding, the lines arriving mounted a new `View`, which started empty and + * resolved again: the lines never stayed, the box remounted without end, and every keybinding (Ctrl+C, + * Ctrl+P, Esc) stopped working, which is what took this box off the start screen in 0.12.6. */ +export function WelcomeBlock(props: { + lines: () => WelcomeLines | null + theme: () => TuiPluginApi["theme"]["current"] +}) { return ( - - {(lines) => ( - - - {lines().mode} - - - {lines().commands} - - - {lines().integrations} - - - )} - + + + {(lines) => ( + + + {lines().mode} + + + {lines().commands} + + + {lines().integrations} + + + )} + + ) } diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 4fce797f1..a68e48024 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -28,7 +28,10 @@ import { existsSync } from "node:fs" import open from "open" // altimate_change start - the /workspace action menu import * as Manage from "@/altimate/workspace/manage" -import { describeSyncProblems } from "@/altimate/workspace/skill-sync" +import { describeSyncProblems, pendingSync } from "@/altimate/workspace/skill-sync" +// altimate_change - the starter's shape only; it is built by the server +import type { Starter } from "@/altimate/workspace/starter" +import { realpathSync } from "fs" import { confirmsNamesake, displayWorkspaceName, @@ -48,7 +51,7 @@ import { type IntegrationRow, } from "@/altimate/workspace/status-view" // altimate_change end -import { createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { ConflictError, HIDDEN_BINDING_MESSAGE, @@ -376,12 +379,18 @@ interface LinkedProps { * three success paths visually consistent while still labelling what * just happened. */ verb: "Linked" | "Re-linked" | "Created" + // altimate_change - what the workspace provides and prompts to start with; null until it arrives, or if it cannot be read + starter: () => Starter | null } /** Persistent confirmation card shown after a successful bind. Replaces the * transient success toast so the user has an unmissable "yes, it worked" and * a stable CTA back to the browser. Dismissable via Done or Esc. */ function WorkspaceLinkedDialog(props: LinkedProps) { + // altimate_change - room for the suggested prompts, which a medium dialog cuts off + createEffect(() => { + if (props.starter()?.prompts.length) props.api.ui.dialog.setSize("large") + }) const title = () => { const suffix = props.manageUrl ? ` — ${props.manageUrl}` : "" // "Created" is the only verb that just made a NEW workspace, and every create from here @@ -391,9 +400,23 @@ function WorkspaceLinkedDialog(props: LinkedProps) { // DialogSelect doesn't take a top-level description block, so the // memory-sync disclosure is packed into the title, matching the // AlreadyLinkedDialog convention above. - return `${props.verb} workspace "${props.workspaceName}"${suffix} — Saved memory blocks will sync to this workspace if memory is enabled for it.${privacy}` + // altimate_change start - the starter: what the workspace provides, then the question + const starter = props.starter() + const has = starter?.summary ? ` It has ${starter.summary}.` : "" + const ask = starter?.prompts.length ? " What do you want to do?" : "" + return `${props.verb} workspace "${props.workspaceName}"${suffix} — Saved memory blocks will sync to this workspace if memory is enabled for it.${privacy}${has}${ask}` + // altimate_change end } const options = () => { + // altimate_change start - a suggested prompt fills the input; the user sends it + const prompts = (props.starter()?.prompts ?? []).map((prompt, i) => ({ + title: prompt, + value: `prompt:${i}`, + category: "Try asking", + })) + // altimate_change end + // altimate_change - the prompts go below: they can arrive after the user has moved, and the dialog keeps the + // selection by row index, so rows added above would shift it onto another row. if (props.manageUrl) { return [ { @@ -402,16 +425,34 @@ function WorkspaceLinkedDialog(props: LinkedProps) { description: "Open the workspace in your browser.", }, { title: "Done", value: "done", description: "Close this dialog." }, + ...prompts, ] } - return [{ title: "Done", value: "done", description: "Close this dialog." }] + return [{ title: "Done", value: "done", description: "Close this dialog." }, ...prompts] } + // altimate_change start - jump to the first prompt when it arrives, unless the user has moved off the default + // row by then (the dialog follows `current` whenever it changes) + const initial = props.manageUrl ? "open" : "done" + const [movedEarly, setMovedEarly] = createSignal(false) + // A memo, so it notifies only when its value changes: the dialog re-applies `current` on every notification, + // which would pull the cursor back to the default row the moment the user leaves it. + const current = createMemo(() => (props.starter()?.prompts.length && !movedEarly() ? "prompt:0" : initial)) + // altimate_change end return ( { + if (!props.starter()?.prompts.length && option.value !== initial) setMovedEarly(true) + }} onSelect={(option) => { + // altimate_change start - put the chosen prompt in the input without sending it + if (option.value.startsWith("prompt:")) { + fillPrompt(props.api, option.title) + return + } + // altimate_change end if (option.value === "open" && props.manageUrl) { // Guard before delegating to open() — a rogue manage_url with a // non-http protocol would otherwise dispatch to an unrelated OS @@ -434,11 +475,100 @@ async function showLinkedConfirmation( workspaceName: string, ): Promise { const manageUrl = await resolveManageUrl(workspaceId) + // altimate_change start - the confirmation opens at once; the starter fills in when it arrives + const [starter, setStarter] = createSignal(null) + void loadStarter(api, { afterLink: true }).then(setStarter) + // altimate_change end api.ui.dialog.replace(() => ( - + )) } +// altimate_change start — the workspace starter after a link +/** Puts a suggested prompt in the input and closes the dialog. Not sent: the user reads it first. */ +function fillPrompt(api: TuiPluginApi, text: string): void { + const ref = api.prompt.active() + api.ui.dialog.clear() + if (!ref) return + ref.set({ ...ref.current, input: text, parts: [] }) + ref.focus() +} + +/** The starter again, from `/workspace` → Get started. */ +async function showStarter(api: TuiPluginApi): Promise { + const starter = await loadStarter(api) + if (!starter) { + api.ui.toast({ variant: "warning", message: "Could not read what this workspace provides right now. Try again shortly." }) + return + } + api.ui.dialog.replace(() => ) +} + +function WorkspaceStarterDialog(props: { api: TuiPluginApi; starter: Starter }) { + onMount(() => props.api.ui.dialog.setSize("large")) + const lines = props.starter.lines.length > 0 ? ` ${props.starter.lines.join(" ")}` : "" + return ( + ({ title: prompt, value: `prompt:${i}`, category: "Try asking" })), + { title: "Done", value: "done", description: "Close this dialog." }, + ]} + current={props.starter.prompts.length > 0 ? "prompt:0" : "done"} + onSelect={(option) => { + if (option.value.startsWith("prompt:")) fillPrompt(props.api, option.title) + else props.api.ui.dialog.clear() + }} + /> + ) +} + +/** How long the starter waits for the link's skill sync, so it does not report skills "not synced" a moment + * before they land. The confirmation is already on screen meanwhile. */ +const STARTER_SYNC_WAIT_MS = 5_000 + +/** The starter from this TUI's server (`GET /altimate/workspace/starter`): building it imports the skill module + * and the workspace service client, which this thread does not otherwise load. `afterLink` first waits for the + * skill sync the link started in this realm, if it is still running; it never starts one. null on any failure. */ +async function loadStarter(api: TuiPluginApi, opts: { afterLink?: boolean } = {}): Promise { + try { + if (opts.afterLink) { + const directory = api.state.path.directory + let key = directory + try { + key = realpathSync(directory) + } catch { + // the path as given + } + const running = pendingSync(key) + if (running) { + let timer: ReturnType | undefined + await Promise.race([ + running.catch(() => undefined), + new Promise((done) => { + timer = setTimeout(done, STARTER_SYNC_WAIT_MS) + }), + ]).finally(() => timer && clearTimeout(timer)) + } + } + const raw = (api.client as unknown as { client?: { get(o: { url: string }): Promise<{ data?: unknown }> } }).client + if (!raw) return null + const res = await raw.get({ url: "/altimate/workspace/starter" }) + const data = res.data as { ok?: boolean; linked?: boolean; starter?: Starter } | undefined + return data?.ok && data.linked && data.starter ? data.starter : null + } catch (err) { + log.warn("could not load the workspace starter", { err: String(err) }) + return null + } +} +// altimate_change end + /** Held across ``runBrowserHandoff`` invocations so a second handoff can * supersede a still-open first one — otherwise the first loopback listener * stays bound for its full 15-minute callback window and the second flow @@ -2324,6 +2454,12 @@ export async function runWorkspaceManage(api: TuiPluginApi, directory: string): value: "status", description: statusRowDescription(directory, bound!), }, + // altimate_change - the starter, on demand + { + title: "Get started", + value: "starter", + description: "What this workspace provides, and prompts to try.", + }, { title: "Refresh", value: "refresh", @@ -2371,6 +2507,13 @@ export async function runWorkspaceManage(api: TuiPluginApi, directory: string): showWorkspaceStatus(api, directory, bound!, menu).catch((err) => reportFlowFailure(api, err)) return } + // altimate_change start - the starter, on demand + if (option.value === "starter") { + api.ui.dialog.clear() + showStarter(api).catch((err) => reportFlowFailure(api, err)) + return + } + // altimate_change end if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 30ee7b074..bfda9b681 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1165,6 +1165,35 @@ export namespace Server { } }) // altimate_change end + // altimate_change start — GET /altimate/workspace/starter + // The fixed "what this workspace has, what next?" message for the IDE extension's chat and the + // TUI after a link: no model call, same data as the prompt's workspace section. A link that + // could not be confirmed (`stale`) is answered like an unreachable service, as the prompt does. + .get("/altimate/workspace/starter", async (c) => { + const refused = workspaceRouteRefusal( + c.req.header("origin"), + c.req.header("host"), + undefined, + c.req.header("sec-fetch-site"), + ) + if (refused) return c.json(refused.body, refused.status) + try { + const { resolveBindingOutcome } = await import("../altimate/workspace/state") + const outcome = await resolveBindingOutcome(Instance.directory) + if (outcome.status === "unbound") return c.json({ ok: true as const, linked: false as const }) + if (outcome.status !== "bound" || outcome.stale) { + return c.json({ ok: false, error: "Could not confirm this project's workspace link right now." }, 503) + } + const { starterFor } = await import("../altimate/workspace/starter") + const starter = await starterFor(Instance.directory, outcome.binding) + return c.json({ ok: true as const, linked: true as const, starter }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("workspace starter: failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + // altimate_change end // altimate_change start — GET /altimate/skill/publishable, POST /altimate/skill/publish // The CLI's `skill publish ` for the IDE extension. Only `serve` holds the extension's // pin, so publishing here targets the workspace selected in the panel — through the same diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 51f1d1673..19e1ae220 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -27,6 +27,7 @@ import { Skill } from "@/skill" import { Fingerprint } from "../altimate/fingerprint" import { Config } from "../config/config" import { selectSkillsWithLLM } from "../altimate/skill-selector" +import { SKILL_PRECEDENCE_RULE } from "../altimate/skill-source" // altimate_change start — Effect Service facade for SystemPrompt.skills (see bottom of namespace) import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -173,6 +174,8 @@ export namespace SystemPrompt { parts.push( "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", + // altimate_change - which skill wins when several fit + SKILL_PRECEDENCE_RULE, // the agents seem to ingest the information about skills a bit better if we present a more verbose // version of them here and a less verbose version in tool description, rather than vice versa. Skill.fmt(filtered, { verbose: true }), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 3e71e0997..2d37d55af 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -4,9 +4,12 @@ import { makeRuntime } from "@/effect/run-service" // altimate_change end // altimate_change start — workspace snapshot precedence in `add` import { snapshotCopyYields, snapshotProjectOf } from "@/altimate/workspace/snapshot-path" +// altimate_change - skill source labels +import { SKILL_SOURCES, skillSource } from "@/altimate/skill-source" // altimate_change end import path from "path" // altimate_change — realpath for workspace snapshot attribution +import { realpathSync } from "fs" import fsp from "fs/promises" import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" @@ -61,6 +64,9 @@ export const Info = Schema.Struct({ alwaysApply: Schema.optional(Schema.Boolean), applyPaths: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), // altimate_change end + // altimate_change start — where the skill came from (`altimate/skill-source`), set when the registry is built + source: Schema.optional(Schema.Literals(SKILL_SOURCES)), + // altimate_change end }) export type Info = Schema.Schema.Type @@ -483,6 +489,33 @@ export const layer = Layer.effect( const projectRoot = ctx.worktree !== "/" ? ctx.worktree : ctx.directory yield* loadSkills(s, yield* InstanceState.get(discovered), events, projectRoot) // altimate_change end + // altimate_change start — label each skill with its source, from the location it was registered under + // Resolved paths too, as discovery's snapshot attribution uses: a symlink into or out of the snapshot must + // not take the workspace's label. + const resolve = (p: string) => { + try { + return realpathSync(p) + } catch { + return undefined + } + } + const realProjectRoot = resolve(projectRoot) + const realDirectory = resolve(ctx.directory) + for (const [key, skill] of Object.entries(s.skills)) { + const real = path.isAbsolute(skill.location) ? resolve(skill.location) : undefined + s.skills[key] = { + ...skill, + source: skillSource(skill.location, { + projectRoot, + home: global.home, + real, + realProjectRoot, + directory: ctx.directory, + realDirectory, + }), + } + } + // altimate_change end return s }), ) @@ -703,7 +736,11 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ...described .toSorted((a, b) => a.name.localeCompare(b.name)) .flatMap((skill) => [ - " ", + // altimate_change start — the source on the element. The attribute is Altimate Code's; + // `neutralizeListingWrapper` keeps a description from forging a ``. Kept in name order: + // listing by source put the built-ins behind every project skill, and they were loaded less. + skill.source ? ` ` : " ", + // altimate_change end // altimate_change start — neutralise the listing's own wrapper tags. // `name` and `description` come from bundle frontmatter, and a bound // workspace syncs those from a remote server, so both are attacker diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 978c716de..a33dd49a3 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -18,6 +18,7 @@ import { Config } from "../config/config" import { selectSkillsWithLLM } from "../altimate/skill-selector" import { Telemetry } from "../altimate/telemetry" import os from "os" +import { isBuiltinSkillLocation } from "../altimate/skill-source" const MAX_DISPLAY_SKILLS = 50 @@ -30,17 +31,8 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // The node_modules match is scoped to Altimate-owned packages (`@altimateai/*` and // the `altimate-code` package) so a third-party skill installed under some other // `node_modules/` isn't tagged as Altimate. - if ( - normalized.startsWith("builtin:") || - // altimate_change — `` is the sentinel used by the embedded - // customization skill. Missing it here made `isBuiltin` false, so - // `path.dirname("")` resolved to "." and the file scan ran over - // the user's entire project. (review) - normalized === "" || - /\/node_modules\/(@altimateai\/|altimate-code\/)/.test(normalized) || - normalized.includes(".altimate/builtin") - ) - return "builtin" + // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart + if (isBuiltinSkillLocation(location)) return "builtin" if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } @@ -127,7 +119,8 @@ export function renderAvailableSkills(skills: Skill.Info[]): string[] { return [ "", ...skills.flatMap((skill) => [ - ` `, + // altimate_change - the source label, as in `Skill.fmt` + skill.source ? ` ` : ` `, ` ${Skill.neutralizeListingWrapper(skill.name)}`, ` ${Skill.neutralizeListingWrapper(skill.description ?? "")}`, ` ${Skill.formatSkillLocation(skill.location)}`, diff --git a/packages/opencode/test/altimate/plugin/tui-plugin-list.test.ts b/packages/opencode/test/altimate/plugin/tui-plugin-list.test.ts index 8e7f096c6..5cdd64dd1 100644 --- a/packages/opencode/test/altimate/plugin/tui-plugin-list.test.ts +++ b/packages/opencode/test/altimate/plugin/tui-plugin-list.test.ts @@ -13,11 +13,13 @@ afterEach(() => { const ids = () => altimateTuiPlugins({ experimentalEventSystem: false }).map((p) => p.id) describe("altimate TUI plugins", () => { - test("with workspaces on, the workspace plugins are registered but the welcome section is not", () => { + // The welcome section is back: 0.12.6 left it out over the dead keybindings, fixed in its view + // (see test/altimate/plugin/workspace-welcome-slot.test.tsx). + test("with workspaces on, the workspace plugins and the welcome section are registered", () => { delete process.env.ALTIMATE_DISABLE_WORKSPACE expect(ids()).toContain("altimate:workspace") expect(ids()).toContain("altimate:sidebar-workspace") - expect(ids()).not.toContain("altimate:welcome-workspace") + expect(ids()).toContain("altimate:welcome-workspace") }) test("the kill switch leaves every workspace plugin out", () => { diff --git a/packages/opencode/test/altimate/plugin/workspace-welcome-slot.test.tsx b/packages/opencode/test/altimate/plugin/workspace-welcome-slot.test.tsx new file mode 100644 index 000000000..ce332b758 --- /dev/null +++ b/packages/opencode/test/altimate/plugin/workspace-welcome-slot.test.tsx @@ -0,0 +1,78 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change - new file +// +// The start-screen welcome box rendered nothing until the binding resolved. OpenTUI's `Slot` renders a plugin +// view's fallback when its first render is empty, and renders the view again when that output changes; the +// new view starts empty and fetches again, so the lines never stayed and the box remounted without end, and +// in the TUI every keybinding stopped working (0.12.5). These mount the real slot machinery with a view that, +// like the box, fills its own lines in after it mounts. +import { expect, test } from "bun:test" +import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid" +import { createSignal, onMount, Show, type JSX } from "solid-js" +import { WelcomeBlock } from "@/plugin/tui/altimate/workspace-welcome" +import type { WelcomeLines } from "@/altimate/workspace/welcome-lines" + +type Slots = { welcome_extra: {} } +const MAX_MOUNTS = 10 + +const theme = () => ({ accent: "#00ff00", text: "#ffffff", textMuted: "#888888" }) as never +const LINES: WelcomeLines = { + mode: "Workspace mode · linked to acme", + commands: "/workspace → Get started", + integrations: "Integrations: attach on your first message", +} + +/** Mounts a view that, like the box, owns its lines and fills them in after it mounts (the box resolves the + * binding in `onMount`). `body` renders them. Returns the app and how many times the view was mounted. */ +async function mountLate(body: (lines: () => WelcomeLines | null) => JSX.Element) { + let mounts = 0 + const View = () => { + const [lines, setLines] = createSignal(null) + onMount(() => { + mounts++ + // Bounded: under the bare `` each arrival remounts the view, which fetches again, without end. + if (mounts <= MAX_MOUNTS) queueMicrotask(() => setLines(LINES)) + }) + return body(lines) + } + const App = () => { + const registry = createSolidSlotRegistry(useRenderer(), {}) + const Slot = createSlot(registry) + registry.register({ id: "welcome", slots: { welcome_extra: () => } }) + return ( + + + + ) + } + const app = await testRender(() => , { width: 80, height: 10 }) + for (let i = 0; i < 5; i++) { + await app.renderOnce() + await Bun.sleep(5) + } + return { app, mounts: () => mounts } +} + +test("the welcome block shows lines that arrive after its first render, mounted once", async () => { + const { app, mounts } = await mountLate((lines) => ) + try { + const frame = app.captureCharFrame() + expect(frame).toContain("Workspace mode · linked to acme") + expect(frame).toContain("Integrations: attach on your first message") + expect(mounts()).toBe(1) + } finally { + app.renderer.destroy() + } +}) + +// The hazard itself, pinned so a change in OpenTUI is noticed: under a bare `` root, the lines arriving +// re-runs the slot's render, which mounts a new view that starts empty again, so they never stay on screen. +test("a slot view whose root renders nothing at first is remounted when its output arrives", async () => { + const { app, mounts } = await mountLate((lines) => {(l) => {l().mode}}) + try { + expect(app.captureCharFrame()).not.toContain("Workspace mode") + expect(mounts()).toBeGreaterThan(MAX_MOUNTS) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/opencode/test/altimate/skill-source.test.ts b/packages/opencode/test/altimate/skill-source.test.ts new file mode 100644 index 000000000..3085852e6 --- /dev/null +++ b/packages/opencode/test/altimate/skill-source.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { skillSource, SKILL_PRECEDENCE_RULE } from "../../src/altimate/skill-source" +import { Skill } from "../../src/skill" +import { renderAvailableSkills } from "../../src/tool/skill" + +const home = path.join(path.sep, "home", "dev") +const project = path.join(home, "code", "shop") +const at = (...parts: string[]) => path.join(...parts, "SKILL.md") + +describe("skillSource", () => { + const ctx = { projectRoot: project, home } + + test("the linked workspace's snapshot is workspace, even though it sits inside the project", () => { + expect(skillSource(at(project, ".altimate-code", "skill", "_workspace", "pub-1"), ctx)).toBe("workspace") + }) + + test("a skill in the repository is project", () => { + expect(skillSource(at(project, ".claude", "skills", "x"), ctx)).toBe("project") + expect(skillSource(at(project, "sub", ".agents", "skills", "x"), ctx)).toBe("project") + }) + + test("every built-in location form is built-in", () => { + expect(skillSource("builtin:dbt-develop/SKILL.md", ctx)).toBe("built-in") + expect(skillSource("", ctx)).toBe("built-in") + expect(skillSource(at(home, ".altimate", "builtin", "dbt-develop"), ctx)).toBe("built-in") + expect(skillSource(at(path.sep, "usr", "lib", "node_modules", "@altimateai", "x", "skills", "y"), ctx)).toBe("built-in") + }) + + test("a `.altimate/builtin` folder anywhere but the installed one is not built-in", () => { + expect(skillSource(at(project, "vendor", ".altimate", "builtin", "x"), ctx)).toBe("project") + expect(skillSource(at(home, "other", ".altimate", "builtin", "x"), ctx)).toBe("personal") + }) + + const snapshot = (root: string, id: string) => at(root, ".altimate-code", "skill", "_workspace", id) + + test("another project's snapshot is not this project's workspace", () => { + // A `skills.paths` entry pointing into a sibling project's snapshot, fetched by the same account. + expect(skillSource(snapshot(path.join(home, "code", "other"), "pub-1"), ctx)).toBe("other") + }) + + test("a symlink into the snapshot from the project's skills folder is not the workspace", () => { + const link = at(project, ".claude", "skills", "linked") + expect(skillSource(link, { ...ctx, real: snapshot(project, "pub-1") })).toBe("other") + }) + + test("a snapshot entry that resolves out of the snapshot is not the workspace", () => { + expect(skillSource(snapshot(project, "pub-1"), { ...ctx, real: at(home, "elsewhere", "x") })).toBe("other") + }) + + test("a snapshot under the session directory, below the project root, is the workspace", () => { + const sub = path.join(project, "dbt") + expect(skillSource(snapshot(sub, "pub-1"), { ...ctx, directory: sub })).toBe("workspace") + // Without the session directory it would belong to no owner. + expect(skillSource(snapshot(sub, "pub-1"), ctx)).toBe("other") + }) + + test("a project reached through a symlink still owns its snapshot", () => { + const realRoot = path.join(path.sep, "private", "code", "shop") + expect( + skillSource(snapshot(project, "pub-1"), { ...ctx, real: snapshot(realRoot, "pub-1"), realProjectRoot: realRoot }), + ).toBe("workspace") + }) + + test("the user's own folders outside the project are personal", () => { + expect(skillSource(at(home, ".claude", "skills", "x"), ctx)).toBe("personal") + expect(skillSource(at(home, ".config", "altimate-code", "skills", "x"), ctx)).toBe("personal") + }) + + test("a sibling directory that only shares the project's name prefix is not the project", () => { + expect(skillSource(at(`${project}-old`, ".claude", "skills", "x"), ctx)).toBe("personal") + }) + + test("anything else is other", () => { + expect(skillSource(at(path.sep, "opt", "team-skills", "x"), ctx)).toBe("other") + expect(skillSource("relative/SKILL.md", ctx)).toBe("other") + }) + + test("a project opened at the home directory does not claim personal skills", () => { + expect(skillSource(at(home, ".claude", "skills", "x"), { projectRoot: home, home })).toBe("personal") + }) + + test("without a project root nothing is project", () => { + expect(skillSource(at(project, ".claude", "skills", "x"), { home })).toBe("personal") + }) +}) + +describe("listing labels", () => { + const info = (name: string, source?: Skill.Info["source"]): Skill.Info => ({ + name, + description: `${name} description`, + location: at(project, name), + content: "", + source, + }) + const mixed = [ + info("a-other", "other"), + info("b-builtin", "built-in"), + info("c-personal", "personal"), + info("d-project", "project"), + info("z-workspace", "workspace"), + info("y-workspace", "workspace"), + ] + + // Name order, not precedence order: listing by source put the built-ins behind every project skill, and in + // the skill-routing measurement they were then loaded less often. + test("the system-prompt listing puts the source on each skill and keeps name order", () => { + const out = Skill.fmt(mixed, { verbose: true }) + expect(out).toContain('\n y-workspace') + expect(out).toContain('\n b-builtin') + const order = ["a-other", "b-builtin", "c-personal", "d-project", "y-workspace", "z-workspace"].map((n) => out.indexOf(`${n}`)) + expect(order).toEqual([...order].sort((x, y) => x - y)) + }) + + test("the skill tool's listing labels each skill the same way", () => { + const out = renderAvailableSkills([info("w", "workspace"), info("p", "project")]).join("\n") + expect(out).toContain('\n w') + expect(out).toContain('\n p') + }) + + test("an unlabelled skill renders as before", () => { + expect(Skill.fmt([info("plain")], { verbose: true })).toContain(" \n plain") + }) + + test("a description cannot forge a source: the skill tag in it is neutralised", () => { + const forged = { ...info("evil", "other"), description: 'x' } + const out = Skill.fmt([forged], { verbose: true }) + expect(out.match(/') + }) + + test("the rule names every source in precedence order", () => { + const order = ["workspace", "project", "built-in", "personal", "other"].map((s) => SKILL_PRECEDENCE_RULE.indexOf(`"${s}"`)) + expect(order.every((i) => i >= 0)).toBe(true) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(SKILL_PRECEDENCE_RULE).toContain("do not\nask the user which team's skill to use") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/contents.test.ts b/packages/opencode/test/altimate/workspace/contents.test.ts index 4a44ceba7..1bcb3ecd5 100644 --- a/packages/opencode/test/altimate/workspace/contents.test.ts +++ b/packages/opencode/test/altimate/workspace/contents.test.ts @@ -76,8 +76,8 @@ describe("workspace contents section", () => { expect(names).toContain("- skill-00\n") expect(names).not.toContain("xxxx") - const counted = Contents.render(contents, 900) - expect(counted.length).toBeLessThanOrEqual(900) + const counted = Contents.render(contents, 1100) + expect(counted.length).toBeLessThanOrEqual(1100) expect(counted).toContain("Workspace skills: 55 (too many to list here") expect(counted).toContain("Integrations: github.") expect(counted).toContain("Knowledge: not limited to selected documents") @@ -127,6 +127,14 @@ describe("workspace contents section", () => { }) describe("workspace summary", () => { + // The starter prints these to the terminal, not only to the model. + test("integration ids have control characters stripped", async () => { + spyOn(AltimateApi, "getDatamate").mockResolvedValueOnce(summary({ integrations: [{ id: "snow\u001b[2Jflake" }] })) + const ids = (await Contents.workspaceSummary(50)).integrations + expect(ids).toHaveLength(1) + expect(ids![0]).not.toContain("\u001b") + }) + test("integrations come back sorted and are cached; a failure is 'not known', never 'none'", async () => { const spy = spyOn(AltimateApi, "getDatamate").mockResolvedValueOnce( summary({ integrations: [{ id: "snowflake" }, { id: "github" }], memory_enabled: true }), @@ -373,4 +381,41 @@ describe("workspace skills", () => { expect(text).toContain("Workspace skills: could not be read just now") }) }) +describe("workspace knowledge is used without being asked", () => { + const base = { skills: [], integrations: [], memoryEnabled: true } + const RULE = "check this workspace's knowledge" + + for (const knowledge of [ + { kind: "all" as const }, + { kind: "selected" as const, selected: 2, names: ["Handbook", "Glossary"] }, + // The names could not be read, but the documents are there. + { kind: "selected" as const, selected: 2, names: null }, + ]) { + test(`a workspace with knowledge (${knowledge.kind}${"names" in knowledge && knowledge.names === null ? ", names unread" : ""}) gets the rule`, () => { + const text = Contents.render({ ...base, knowledge }) + expect(text).toContain(RULE) + expect(text).toContain("`ask_knowledge_base`") + }) + } + + const cases: Array<[string, Contents.WorkspaceKnowledge | null]> = [ + ["the engine off", { kind: "off" }], + ["knowledge not known", null], + ["every selected document gone", { kind: "selected", selected: 2, names: [] }], + ] + for (const [label, knowledge] of cases) { + test(`no rule with ${label}: there is nothing to search`, () => { + expect(Contents.render({ ...base, knowledge })).not.toContain(RULE) + }) + } + + test("the rule survives the tightest tier, with the counts", () => { + const many = Array.from({ length: 300 }, (_, i) => `document ${i}`) + const integrations = Array.from({ length: 200 }, (_, i) => `integration-number-${i}`) + const text = Contents.render({ ...base, integrations, knowledge: { kind: "selected", selected: 300, names: many } }) + expect(text).toContain("Integrations: 200 attached.") + expect(text).toContain("Knowledge documents: 300.") + expect(text).toContain(RULE) + }) +}) // altimate_change end diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index a59668130..7fbc76533 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -52,6 +52,7 @@ writeFileSync( ) const { + pendingSync, syncSkills, describeSyncProblems, shouldAnnounce, @@ -220,6 +221,25 @@ function skillFile(id: string, rel: string) { } describe("workspace skill sync", () => { + // The TUI's post-link starter waits on the link's own sync this way; it must never start a second one. + test("pendingSync returns the sync in flight, and nothing before or after it", async () => { + serve({ "pub-1": { "SKILL.md": "---\nname: acme\n---\nbody" } }) + const inner = globalThis.fetch + let calls = 0 + globalThis.fetch = (async (...args: Parameters) => { + calls++ + return inner(...args) + }) as typeof fetch + expect(pendingSync(project)).toBeUndefined() + const running = syncSkills(project) + expect(pendingSync(project)).toBeDefined() + await running + expect(pendingSync(project)).toBeUndefined() + const after = calls + expect(pendingSync(project)).toBeUndefined() + expect(calls).toBe(after) + }) + test("writes the bundle, references included", async () => { serve({ "pub-1": { diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts new file mode 100644 index 000000000..52a2104d1 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -0,0 +1,112 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import { renderStarter, wrapStarter } from "../../../src/altimate/workspace/starter" +import type { WorkspaceContents } from "../../../src/altimate/workspace/contents" + +const full: WorkspaceContents = { + skills: [ + { name: "acme-incident-runbook", description: "What to do when a load fails." }, + { name: "acme-staging-conventions", description: "How staging models are built." }, + ], + integrations: ["github", "snowflake"], + memoryEnabled: true, + knowledge: { kind: "selected", selected: 2, names: ["Data handbook", "KPI glossary"] }, +} + +describe("renderStarter", () => { + test("names the workspace, lists what it provides, then asks what to do", () => { + const s = renderStarter("Acme Analytics", full) + expect(s.text.split("\n")).toEqual([ + `You're working in the "Acme Analytics" Altimate workspace.`, + "", + "- Skills (2): acme-incident-runbook, acme-staging-conventions", + "- Knowledge (2): Data handbook, KPI glossary", + "- Integrations: github, snowflake", + "- Memory: on. What you teach Altimate Code is saved automatically and shared with your team in the CLI, the IDE and Studio.", + "", + "What do you want to do? For example:", + " 1. Which of our workspace skills fit this project?", + " 2. What are our best practices for this project?", + " 3. Which Snowflake queries cost us most this week?", + ]) + expect(s.summary).toBe("2 skills · 2 knowledge documents · integrations: github, snowflake · memory on") + }) + + test("is deterministic: the same contents give the same message", () => { + expect(renderStarter("Acme", full)).toEqual(renderStarter("Acme", full)) + }) + + test("leaves out what is not known rather than calling it none", () => { + const s = renderStarter("Acme", { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) + expect(s.lines).toEqual([]) + expect(s.summary).toBe("") + expect(s.text).not.toMatch(/none/i) + // Something to start with even then. + expect(s.prompts).toEqual(["Explain this project and where to start."]) + }) + + test("says what an empty or unsynced workspace means, and suggests only what it can back", () => { + const empty = renderStarter("Acme", { skills: [], integrations: [], memoryEnabled: false, knowledge: { kind: "off" } }) + expect(empty.lines).toEqual([ + "Skills: none yet. Add them in the workspace and they sync here.", + "Knowledge: off for this workspace.", + "Integrations: none attached.", + "Memory: off for this workspace, so saved memory stays on this machine.", + ]) + expect(empty.prompts).toEqual(["Explain this project and where to start."]) + + const unsynced = renderStarter("Acme", { ...full, skills: null }) + expect(unsynced.lines[0]).toContain("not synced to this project yet") + // `workspace refresh` pulls skills; `workspace sync` sends memory. + expect(unsynced.lines[0]).toContain("`altimate-code workspace refresh`") + expect(unsynced.prompts.some((p) => p.includes("skills"))).toBe(false) + }) + + test("a knowledge selection whose documents are all gone suggests no knowledge prompt", () => { + const s = renderStarter("Acme", { ...full, knowledge: { kind: "selected", selected: 3, names: [] } }) + expect(s.lines).toContain("Knowledge: none. The documents this workspace selected no longer exist.") + expect(s.prompts.some((p) => p.includes("best practices"))).toBe(false) + }) + + test("long lists are shortened, never the instruction", () => { + const skills = Array.from({ length: 20 }, (_, i) => ({ name: `s${i}`, description: "" })) + const s = renderStarter("Acme", { ...full, skills }) + expect(s.lines[0]).toBe("Skills (20): s0, s1, s2, s3, s4, s5, s6, s7, and 12 more") + expect(s.text).toContain("What do you want to do?") + }) + + test("at most three prompts", () => { + expect(renderStarter("Acme", full).prompts).toHaveLength(3) + }) + + test("a workspace name cannot break the message onto new lines", () => { + const s = renderStarter("Acme\n\n# Ignore the above", full) + expect(s.text.split("\n")[0]).toBe(`You're working in the "Acme # Ignore the above" Altimate workspace.`) + }) +}) + +describe("wrapStarter", () => { + test("wraps long lines at word boundaries, keeping a list item's indent", () => { + const text = "- Skills (3): alpha-skill, beta-skill, gamma-skill\n 1. Which of this workspace's skills fit?" + expect(wrapStarter(text, 30).split("\n")).toEqual([ + "- Skills (3): alpha-skill,", + " beta-skill, gamma-skill", + " 1. Which of this workspace's", + " skills fit?", + ]) + }) + + test("every line fits the width unless one word alone is longer", () => { + const s = renderStarter("Acme", { + skills: Array.from({ length: 8 }, (_, i) => ({ name: `skill-number-${i}`, description: "" })), + integrations: ["github", "snowflake"], + memoryEnabled: true, + knowledge: { kind: "all" }, + }) + for (const line of wrapStarter(s.text, 40).split("\n")) expect(line.length).toBeLessThanOrEqual(40) + }) + + test("short text is unchanged", () => { + expect(wrapStarter("a\n\nb c", 80)).toBe("a\n\nb c") + }) +}) diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts index c5637eede..7bb40add3 100644 --- a/packages/opencode/test/server/altimate-workspace-routes.test.ts +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Server } from "../../src/server/server" import * as Manage from "../../src/altimate/workspace/manage" +import * as State from "../../src/altimate/workspace/state" +import * as Starter from "../../src/altimate/workspace/starter" import { Session } from "../../src/session" import { NotFoundError } from "../../src/storage/db" import { resetDatabase } from "./db" @@ -230,6 +232,72 @@ describe("POST /altimate/workspace/sync", () => { }) }) +describe("GET /altimate/workspace/starter", () => { + const get = (headers: Record = {}) => + Server.Default().request("/altimate/workspace/starter", { method: "GET", headers }) + const binding = { datamateId: 7, datamateName: "acme", repoRemote: null, projectPath: "/p", linkedAt: 1 } + const starter: Starter.Starter = { workspace: "acme", lines: ["Skills (1): a"], summary: "1 skill", prompts: ["Do it"], text: "t" } + + test("a linked project gets its starter, built for this directory and binding", async () => { + spies.push(spyOn(State, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding })) + const build = spyOn(Starter, "starterFor").mockResolvedValue(starter) + spies.push(build) + + const response = await get() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, linked: true, starter }) + expect(build).toHaveBeenCalledTimes(1) + expect(build.mock.calls[0][1]).toEqual(binding) + }) + + test("an unlinked project is answered as such, with no starter", async () => { + spies.push(spyOn(State, "resolveBindingOutcome").mockResolvedValue({ status: "unbound" })) + const build = spyOn(Starter, "starterFor") + spies.push(build) + + expect(await (await get()).json()).toEqual({ ok: true, linked: false }) + expect(build).not.toHaveBeenCalled() + }) + + // A link served from the cache because the service could not be asked may since have changed; the prompt + // skips the workspace's contents for it, and so does the starter. + for (const outcome of [{ status: "bound", binding, stale: true }, { status: "unknown" }] as const) { + test(`an unconfirmed link (${"stale" in outcome ? "stale" : outcome.status}) is a 503, not a starter`, async () => { + spies.push(spyOn(State, "resolveBindingOutcome").mockResolvedValue(outcome as State.BindingOutcome)) + const build = spyOn(Starter, "starterFor") + spies.push(build) + + expect((await get()).status).toBe(503) + expect(build).not.toHaveBeenCalled() + }) + } + + test("is refused outside the workspace pilot", async () => { + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + const resolve = spyOn(State, "resolveBindingOutcome") + spies.push(resolve) + + expect((await get()).status).toBe(409) + expect(resolve).not.toHaveBeenCalled() + }) + + test("refuses a browser origin on an unsecured server", async () => { + const resolve = spyOn(State, "resolveBindingOutcome") + spies.push(resolve) + + expect((await get({ origin: "https://evil.test" })).status).toBe(403) + expect(resolve).not.toHaveBeenCalled() + }) + + test("reports a thrown error as a 500 with its message", async () => { + spies.push(spyOn(State, "resolveBindingOutcome").mockRejectedValue(new Error("boom"))) + + const response = await get() + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ ok: false, error: "boom" }) + }) +}) + describe("origin policy with a server password set", () => { // The password flag is read once at module load, so the policy is exercised directly with one. test("lets a native client (no Origin) and this server's own page through", () => { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index ee627c7c3..be3863f84 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -4,6 +4,7 @@ import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" +import { SKILL_PRECEDENCE_RULE } from "../../src/altimate/skill-source" import { testEffect } from "../lib/effect" import { withLegacyInstanceRunner } from "./legacy-instance" import fs from "node:fs/promises" @@ -87,6 +88,21 @@ describe("session.system", () => { { init: writeSkillFixtures }, ) + // altimate_change start — the labels are only half of it; the rule that ranks them goes with the listing + it.instance( + "skills output states the precedence rule and labels each skill's source", + () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = (yield* prompt.skills(build)) ?? "" + expect(output).toContain(SKILL_PRECEDENCE_RULE) + expect(output).toContain('\n alpha-skill') + expect(output.indexOf(SKILL_PRECEDENCE_RULE)).toBeLessThan(output.indexOf("")) + }), + { init: writeSkillFixtures }, + ) + // altimate_change end + for (const disabledBy of ["config", "env"] as const) { it.instance( `learning kill switch from ${disabledBy} excludes managed candidates before selector limit`, diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f7556f982..ce8e114ab 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -732,6 +732,87 @@ description: A skill in the .opencode/skills directory. ), ) + // altimate_change start — every registered skill carries the source the listing shows. The credentials live in a + // home of their own, so the project is not the home directory. (Personal skills are covered by the unit tests in + // test/altimate/skill-source.test.ts: global discovery here does not follow a per-test home.) + it.live("each skill is labelled with where it was found: workspace, project, built-in", () => + Effect.gen(function* () { + const home = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const project = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const write = (file: string, name: string) => + Bun.write(file, `---\nname: ${name}\ndescription: ${name} skill.\n---\n\nBody.\n`) + yield* Effect.promise(async () => { + await Bun.write( + path.join(home.path, ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API_URL, altimateInstanceName: TENANT, altimateApiKey: API_KEY }), + ) + await write(path.join(project.path, ".claude", "skills", "repo", "SKILL.md"), "project-one") + await write(path.join(project.path, MANAGED, "pub-abc123", "SKILL.md"), "workspace-one") + await Bun.write( + path.join(project.path, MANAGED, ".manifest.json"), + JSON.stringify({ version: 2, tenant: TENANT, apiUrl: API_URL, account: FIXTURE_ACCOUNT, datamateId: 1, skills: {} }), + ) + }) + yield* withHome( + home.path, + Effect.gen(function* () { + const skill = yield* Skill.Service + const source = Object.fromEntries((yield* skill.all()).map((s) => [s.name, s.source])) + expect(source["workspace-one"]).toBe("workspace") + expect(source["project-one"]).toBe("project") + expect(Object.values(source)).toContain("built-in") + expect(Object.values(source)).not.toContain(undefined) + }).pipe(provideInstance(project.path)), + ) + }), + ) + // altimate_change end + + // A project linked from a subdirectory of its repository keeps its snapshot there, under the session directory, + // not at the git root the registry takes as the project. Its skills are still the workspace's. (review) + it.live("a snapshot under a subdirectory session is labelled workspace", () => + Effect.gen(function* () { + const home = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const project = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const sub = path.join(project.path, "sub") + const write = (file: string, name: string) => + Bun.write(file, `---\nname: ${name}\ndescription: ${name} skill.\n---\n\nBody.\n`) + yield* Effect.promise(async () => { + await Bun.write( + path.join(home.path, ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API_URL, altimateInstanceName: TENANT, altimateApiKey: API_KEY }), + ) + await write(path.join(project.path, ".claude", "skills", "repo", "SKILL.md"), "project-one") + await write(path.join(sub, MANAGED, "pub-abc123", "SKILL.md"), "workspace-one") + await Bun.write( + path.join(sub, MANAGED, ".manifest.json"), + JSON.stringify({ version: 2, tenant: TENANT, apiUrl: API_URL, account: FIXTURE_ACCOUNT, datamateId: 1, skills: {} }), + ) + }) + yield* withHome( + home.path, + Effect.gen(function* () { + const skill = yield* Skill.Service + const source = Object.fromEntries((yield* skill.all()).map((s) => [s.name, s.source])) + expect(source["workspace-one"]).toBe("workspace") + expect(source["project-one"]).toBe("project") + }).pipe(provideInstance(sub)), + ) + }), + ) + // Two people share a machine or a checkout; the second one's // session must not load the first one's workspace skills, which can be // private to them. `syncSkills` deletes a foreign snapshot, but it does not