04b58ce01e
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows theb7d6fc7widening per the operator's call, and fixes a regression it introduced. Decision recorded as note 2094. Two scopes now, deliberately different: readable_notes_clause — everything the ACL permits, including records reached only via a direct/group note share. For EXPLICIT acts: a search the caller typed, a fetch by id. browsable_notes_clause — the caller's own records plus anything in a project they can reach. For PASSIVE surfaces: browse lists, facet counts, the process->skill manifest. The split is a trust boundary. Anything appearing unasked — in your own list, your own counts, or as a skill installed on your machine — reads as material you endorsed. A one-off someone shared with you hasn't earned that standing, so it waits until you go looking. This also dissolves the shared-Process problem by construction rather than by special case: the manifest is a passive surface, so a directly-shared Process is never installed as an auto-surfacing skill. Regression fix (#2093):b7d6fc7widened the list queries but left the fetch path owner-only, so on the MCP path a record could be listed and then not opened — get_snippet raised not-found, get_process couldn't resolve, and the manifest emitted stubs whose get_process call would fail. snippets.get_snippet and notes.resolve_process now resolve the read scope. delete_snippet gained an explicit can_write_note guard, since being able to SEE a shared snippet must not imply being able to bin it. Provenance, so nothing arrives looking like the operator's own work: - access.describe_provenance / label_shared_items add shared/owner/permission; labelling costs no query when everything is the caller's own. - MCP: get_snippet, list_snippets, get_process and list_processes carry it, and get_process now says outright NOT to follow a shared process verbatim — its follow-as-written contract was the sharpest instance of the problem. - The skill stub for a shared Process names its author and asks for a go-ahead, instead of describing it as "the operator's saved Scribe process". - Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a shared record is that person's suggestion, weigh it, attribute it, ask before adopting it. - UI: shared snippets show "by <owner>" in the list and a notice above the code in the detail view, reusing SharedWithMeView's vocabulary. Plugin 0.1.15 -> 0.1.16 (skill text changed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
|
|
/** One canonical location of a reusable thing. A snippet that unifies several
|
|
* one-offs carries several — one per call site. */
|
|
export interface SnippetLocation {
|
|
repo: string;
|
|
path: string;
|
|
symbol: string;
|
|
}
|
|
|
|
/** Structured fields parsed out of a snippet note (mirrors the backend
|
|
* `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol`
|
|
* mirror the first location for back-compat; `locations` is the full list. */
|
|
export interface SnippetFields {
|
|
name: string;
|
|
when_to_use: string;
|
|
signature: string;
|
|
language: string;
|
|
repo: string;
|
|
path: string;
|
|
symbol: string;
|
|
locations: SnippetLocation[];
|
|
code: string;
|
|
}
|
|
|
|
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
|
|
* as returned by the backend `snippet_to_dict`. */
|
|
export interface Snippet {
|
|
id: number;
|
|
title: string;
|
|
body: string;
|
|
tags: string[];
|
|
note_type: string;
|
|
project_id: number | null;
|
|
permission?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
snippet: SnippetFields;
|
|
systems?: { id: number; name: string }[];
|
|
/** Set when another user owns this record; `owner` is their username and
|
|
* `permission` your access level on it. */
|
|
shared?: boolean;
|
|
owner?: string | null;
|
|
}
|
|
|
|
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
|
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
|
* the parsed fields above. */
|
|
export interface SnippetListItem {
|
|
id: number;
|
|
title: string;
|
|
tags: string[];
|
|
note_type: string;
|
|
snippet: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
/** Present only when the record belongs to someone else — a suggestion from
|
|
* them, not one of your own. Absent means it's yours. */
|
|
shared?: boolean;
|
|
owner?: string | null;
|
|
}
|
|
|
|
/** Create/update payload — discrete fields the backend serializes into the
|
|
* note (title/body/tags). Empty strings are applied; omitted keys are left
|
|
* unchanged on update. */
|
|
export interface SnippetInput {
|
|
name: string;
|
|
code: string;
|
|
language?: string;
|
|
signature?: string;
|
|
when_to_use?: string;
|
|
locations?: SnippetLocation[];
|
|
tags?: string[];
|
|
project_id?: number | null;
|
|
system_ids?: number[];
|
|
/** Create only: record it even though a near-duplicate already exists. */
|
|
force?: boolean;
|
|
}
|
|
|
|
export async function listSnippets(
|
|
params: { q?: string; tag?: string; projectId?: number | null } = {},
|
|
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
|
const qs = new URLSearchParams();
|
|
if (params.q) qs.set("q", params.q);
|
|
if (params.tag) qs.set("tag", params.tag);
|
|
if (params.projectId) qs.set("project_id", String(params.projectId));
|
|
const query = qs.toString();
|
|
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
export async function getSnippet(id: number): Promise<Snippet> {
|
|
return apiGet(`/api/snippets/${id}`);
|
|
}
|
|
|
|
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
|
|
return apiPost("/api/snippets", data);
|
|
}
|
|
|
|
export async function updateSnippet(
|
|
id: number,
|
|
data: Partial<SnippetInput>,
|
|
): Promise<Snippet> {
|
|
return apiPatch(`/api/snippets/${id}`, data);
|
|
}
|
|
|
|
export async function deleteSnippet(id: number): Promise<void> {
|
|
return apiDelete(`/api/snippets/${id}`);
|
|
}
|
|
|
|
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
|
|
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
|
export async function mergeSnippets(
|
|
targetId: number,
|
|
sourceIds: number[],
|
|
): Promise<Snippet & { merged_ids: number[] }> {
|
|
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
|
|
}
|