drafted 1.19.6 → 1.19.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/mcp/server.mjs +154 -16
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -384,11 +384,11 @@ const TOOL_ANNOTATIONS = {
384
384
  auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
385
385
 
386
386
  // Identity — read-only introspection of THIS agent's session
387
- whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
387
+ whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
388
388
 
389
389
  // Session naming — the name-before-work gate: every agent session must set a short
390
390
  // name describing the work before any other tool call succeeds.
391
- session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param).' },
391
+ session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param) or `update` (update the npm-installed Drafted MCP on THIS machine when whoami reports mcpUpdate.stale — run it yourself rather than handing the user a shell command; `dryRun` reports without starting it).' },
392
392
 
393
393
  // Comments — the review loop agents could previously only reach over raw HTTP
394
394
  comment: { title: 'Comments', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Read and write review comments on frames. Comments are notes ABOUT THE WORK: they attach to a frame, optionally to one element inside it, and anyone who can see the frame sees them. Dispatch by `action`: list (paginated, compact mode), add (with an optional element anchor or a reply), resolve, reopen, delete. Use this to leave findings a human can action in place, and to read the feedback they left you.' },
@@ -401,7 +401,7 @@ const TOOL_ANNOTATIONS = {
401
401
  // Minions — checklist-driven intake surfaces bound to a project
402
402
  minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and write a producible into the project. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled). Requires the agent allowlist.' },
403
403
  trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately, not retrievable later), list, update (enable/disable, edit template, daily limit, executor), rotate (new token), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
404
- fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search.' },
404
+ fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search, link, unlink, links.' },
405
405
  repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), add (link a repo to a folder, --branch optional), rescan (re-fetch the tracked branch), entries (search the index). Content stays in git; Drafted keeps a read-only index.' },
406
406
  };
407
407
 
@@ -413,7 +413,7 @@ function isMutatingToolCall(name, args = {}) {
413
413
  case 'trigger':
414
414
  return ['create', 'update', 'rotate', 'delete'].includes(action);
415
415
  case 'fs':
416
- return !['ls', 'read', 'search'].includes(action);
416
+ return !['ls', 'read', 'search', 'links'].includes(action);
417
417
  default:
418
418
  return false;
419
419
  }
@@ -2204,6 +2204,8 @@ async function sessionSurfaceBlock() {
2204
2204
  if (agentSurface) {
2205
2205
  return {
2206
2206
  sessionId,
2207
+ authenticated: !!agentSurface.userId,
2208
+ authState: agentSurface.userId ? 'authenticated' : 'signed_out',
2207
2209
  userId: agentSurface.userId ?? null,
2208
2210
  orgId: agentSurface.orgId ?? null,
2209
2211
  projectId: agentSurface.projectId ?? null,
@@ -2232,6 +2234,7 @@ async function sessionSurfaceBlock() {
2232
2234
  try {
2233
2235
  const res = await serverFetch(`${getServerUrl()}/auth/me`, { headers: { Cookie: `gc_session=${cookieSid}` } });
2234
2236
  if (res.ok) me = await res.json();
2237
+ // else: the server ANSWERED and rejected this session — a real sign-out, not a blip.
2235
2238
  } catch (e) {
2236
2239
  // Transport failure, NOT a sign-out. Conflating the two made agents tell
2237
2240
  // users "you're signed out" during a network blip and start needless
@@ -2242,8 +2245,26 @@ async function sessionSurfaceBlock() {
2242
2245
  }
2243
2246
  }
2244
2247
  const nameRequired = !!me?.agentClone && !me?.surfaceName;
2248
+ // A null userId used to come back BARE — no reason, no next step — and agents filled the
2249
+ // gap by inventing one: a real report has an agent telling the user to look for a pending
2250
+ // "device/session approval prompt" in the desktop app (no such prompt exists) and then
2251
+ // waiting on it. Name which of the four states this is and the exact call that ends it.
2252
+ const pending = (!me && !unreachable) ? getPendingDeviceCode() : null;
2253
+ const authState = me?.userId ? 'authenticated'
2254
+ : unreachable ? 'server_unreachable'
2255
+ : pending ? 'awaiting_approval'
2256
+ : cookieSid ? 'session_rejected'
2257
+ : 'signed_out';
2258
+ const AUTH_NOTES = {
2259
+ awaiting_approval: `Sign-in started but NOT yet approved. Give the user this link and ask them to open it: ${pending?.verificationUrl || '(run auth(action="get_link") for a fresh link)'} — this session authenticates itself once they approve; nothing to approve inside the desktop app.`,
2260
+ session_rejected: 'Not signed in: this machine has a stored Drafted session but the server rejected it (expired or revoked). Call auth(action="get_link") and give the user the returned URL. Do NOT ask them to look for an approval prompt in the desktop app — being signed in to the app does not sign THIS agent in.',
2261
+ signed_out: 'Not signed in: no Drafted session on this machine. Call auth(action="get_link") and give the user the returned URL. Do NOT ask them to look for an approval prompt in the desktop app — being signed in to the app does not sign THIS agent in.',
2262
+ };
2245
2263
  return {
2246
2264
  sessionId: cookieSid,
2265
+ authenticated: !!me?.userId,
2266
+ authState,
2267
+ ...(AUTH_NOTES[authState] ? { authNote: AUTH_NOTES[authState] } : {}),
2247
2268
  userId: me?.userId ?? null,
2248
2269
  orgId: me?.currentOrg?.id ?? null,
2249
2270
  projectId: getState().projectId ?? null,
@@ -2254,6 +2275,9 @@ async function sessionSurfaceBlock() {
2254
2275
  color: null,
2255
2276
  surfaced: false,
2256
2277
  alive: false,
2278
+ // `alive` is bare here where it isn't in the acked branch, and a bare false reads as
2279
+ // "something is broken" — it only means the surface WebSocket hasn't acked yet.
2280
+ aliveMeaning: 'The surface WebSocket has not acked this session yet, so no canvas is known to be open. Frame, wiki, and skill writes all persist normally — only focus/presence have nothing to draw on. Do not change what you write because of this, and do not report it as an error.',
2257
2281
  ...(unreachable ? {
2258
2282
  serverUnreachable: true,
2259
2283
  note: unreachableWhy || `Could not reach ${getServerUrl()} — identity UNKNOWN, not signed out. This is a network/transport failure: do not tell the user they are logged out and do not start a new login; retry when connectivity is back.`,
@@ -2285,7 +2309,7 @@ async function getGoogleDriveAvailability() {
2285
2309
  // reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
2286
2310
  // cached (5min), so repeat `whoami` calls are free; the server-side update gate still
2287
2311
  // blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
2288
- tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
2312
+ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
2289
2313
  try {
2290
2314
  // Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
2291
2315
  // fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
@@ -2295,7 +2319,19 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
2295
2319
  // Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
2296
2320
  // enough; without an explicit instruction agents rarely say which session they are, so users
2297
2321
  // can't match them to their tab on the Drafted surface.
2298
- const instruction = block.nameRequired
2322
+ // Auth outranks naming: an unauthenticated session has nothing to name, and leaving the
2323
+ // instruction empty is what let agents invent their own explanation for the null ids.
2324
+ // A stale install can now fix ITSELF — say so here, since this is where staleness is
2325
+ // reported and an agent that only reads `stale: true` goes back to the user with a shell
2326
+ // command they didn't ask for.
2327
+ const staleNote = mcpUpdate.stale && mcpUpdate.mode === 'stdio'
2328
+ ? ` This machine's installed Drafted MCP is out of date (${mcpUpdate.currentVersion} → ${mcpUpdate.latestVersion || 'latest'})${mcpUpdate.required ? ' and BELOW the minimum required version, so mutating tools are blocked' : ''}. Call session(action="update") to update it yourself — do not hand the user a shell command.`
2329
+ : '';
2330
+ const instruction = block.authNote
2331
+ ? block.authNote + staleNote
2332
+ : staleNote && !block.nameRequired
2333
+ ? (block.name ? `You are the session named "${block.name}". Tell the user you're "${block.name}" in your reply so they can match you to your tab on the Drafted surface.` : '') + staleNote
2334
+ : block.nameRequired
2299
2335
  ? `You have NO session name yet — the name-before-work gate blocks all other tools until you set one. Name this session by the work you're doing with the user: a short 2-3 word description (suggested: "${block.suggestedName || '...'}"). Set it now with session(action="name", name="<your name>"), then tell the user you're "<name>".`
2300
2336
  : block.name
2301
2337
  ? `You are the session named "${block.name}". Tell the user you're "${block.name}" in your reply so they can match you to your tab on the Drafted surface.`
@@ -2314,13 +2350,69 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
2314
2350
  } catch (error) { return err(error); }
2315
2351
  });
2316
2352
 
2353
+ // Self-update: run the installed CLI's own updater so THIS machine's stale stdio MCP can be
2354
+ // refreshed from inside a tool call. Before this, the only path was telling the user to run a
2355
+ // shell command — which a web/Cowork agent cannot do at all, and which a stale install needs
2356
+ // most (an out-of-date MCP is exactly the session least able to ask for help). The updater
2357
+ // mechanics live in ONE place, `drafted update --yes` (cli/drafted.mjs) — do not reimplement
2358
+ // the installer command here, that's the two-writers drift.
2359
+ //
2360
+ // The `drafted` bin is resolved at the FIXED prefix first: the stdio MCP resolves from
2361
+ // ~/.drafted/npm-global, which a non-login shell's PATH does not carry, so bare `drafted`
2362
+ // finds nothing (or worse, a different install) on many machines.
2363
+ function installedDraftedBin() {
2364
+ const prefixed = platform() === 'win32'
2365
+ ? join(homedir(), '.drafted', 'npm-global', 'drafted.cmd')
2366
+ : join(homedir(), '.drafted', 'npm-global', 'bin', 'drafted');
2367
+ return existsSync(prefixed) ? prefixed : 'drafted';
2368
+ }
2369
+
2370
+ async function updateInstalledMcp({ dryRun = false } = {}) {
2371
+ try {
2372
+ const metadata = await getMcpUpdateMetadata();
2373
+ const instructions = buildInstalledMcpUpdateInstructions(metadata);
2374
+ // Hosted HTTP MCP has no local daemon — instructions already say so.
2375
+ if (!instructions.updateSupported) return ok({ ...instructions, action: 'update' });
2376
+ if (dryRun) return ok({ ...instructions, action: 'update', started: false, dryRun: true });
2377
+
2378
+ const bin = installedDraftedBin();
2379
+ const result = await new Promise((resolve) => {
2380
+ execFile(bin, ['update', '--yes', '--json'], { timeout: 60_000 }, (error, stdout) => {
2381
+ if (error) return resolve({ ok: false, error: error.code === 'ENOENT' ? `Drafted CLI not found (looked for ${bin})` : (error.killed ? 'Updater did not start within 60s' : error.message) });
2382
+ try { resolve({ ok: true, data: JSON.parse(stdout) }); }
2383
+ catch { resolve({ ok: true, data: null }); }
2384
+ });
2385
+ });
2386
+
2387
+ if (!result.ok) {
2388
+ return ok({
2389
+ ...instructions,
2390
+ action: 'update',
2391
+ started: false,
2392
+ error: result.error,
2393
+ note: `Could not start the updater from this process (${result.error}). Give the user this command to run in a terminal instead: ${instructions.manualCommand}`,
2394
+ });
2395
+ }
2396
+ return ok({
2397
+ ...instructions,
2398
+ action: 'update',
2399
+ started: true,
2400
+ // Be exact about what did and did not happen: the installer runs DETACHED and does not
2401
+ // replace the already-loaded process, so "updated" would be a lie until a restart.
2402
+ note: 'The updater is running in the background (~30-60s). It does NOT replace this already-running MCP process: tell the user to restart their agent/editor once it finishes, then call whoami to confirm the new mcpVersion.',
2403
+ });
2404
+ } catch (error) { return err(error); }
2405
+ }
2406
+
2317
2407
  // Session naming: set/rename THIS agent session's name — the name-before-work gate stays
2318
2408
  // closed until this succeeds. The name persists server-side (sessions.surface_name), so
2319
2409
  // reconnects and server restarts keep it and the gate never re-fires for a named session.
2320
- tool('session', 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes.', {
2321
- action: z.enum(['name']).describe('Operation — currently only `name` (set/rename this session).'),
2410
+ tool('session', 'Manage THIS agent session. `action="name"` sets/renames it the name is what the user sees on your surface tab, so pick a short 2-3 word description of the work (e.g. "beoflow backend"); it persists across reconnects and restarts. `action="update"` updates the npm-installed Drafted MCP on THIS machine when whoami reports mcpUpdate.stale — run it yourself instead of asking the user to run a shell command.', {
2411
+ action: z.enum(['name', 'update']).describe('Operation: `name` (set/rename this session) or `update` (update the installed Drafted MCP on this machine).'),
2322
2412
  name: z.string().optional().describe('[name] the session name — a short 2-3 word description of the work (e.g. "beoflow backend"). Max 5 words / 50 chars.'),
2323
- }, async ({ action, name }) => {
2413
+ dryRun: z.boolean().optional().describe('[update] report what the update would do without starting it.'),
2414
+ }, async ({ action, name, dryRun }) => {
2415
+ if (action === 'update') return updateInstalledMcp({ dryRun });
2324
2416
  if (action !== 'name') return err(new Error(`unknown session action: ${action}`));
2325
2417
  if (!name || !String(name).trim()) return err(new Error('name required — a short 2-3 word description of the work, e.g. session(action="name", name="beoflow backend")'));
2326
2418
  try {
@@ -2823,10 +2915,11 @@ server.resource('info', 'drafted://info', {
2823
2915
  };
2824
2916
  });
2825
2917
 
2826
- tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2827
- action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
2918
+ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2919
+ action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
2828
2920
  path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/tasks/... | /o/<org>/projects/... (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
2829
- to: z.string().optional().describe('[mv] destination path'),
2921
+ to: z.string().optional().describe('[mv] destination path; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
2922
+ url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
2830
2923
  query: z.string().optional().describe('[search] term to match against names/content'),
2831
2924
  content: z.string().optional().describe('[write] inline HTML/markdown/text'),
2832
2925
  file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
@@ -2861,10 +2954,10 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2861
2954
  const toScoped = splitOrgScope(stripUrlOrigin(to));
2862
2955
  if (toScoped.error) return err(new Error(toScoped.error));
2863
2956
  if (toScoped.org && orgFromPath && toScoped.org.toLowerCase() !== orgFromPath.toLowerCase()) {
2864
- return err(new Error('mv stays within one org — source and destination must address the same org'));
2957
+ return err(new Error(`${action} stays within one org — source and destination must address the same org`));
2865
2958
  }
2866
2959
  if (toScoped.org && !orgFromPath) {
2867
- return err(new Error('mv to an org-scoped destination needs an org-scoped source too (/o/<org>/... on both sides)'));
2960
+ return err(new Error(`${action} to an org-scoped destination needs an org-scoped source too (/o/<org>/... on both sides)`));
2868
2961
  }
2869
2962
  to = toScoped.path;
2870
2963
  }
@@ -3180,7 +3273,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3180
3273
  // so a task moved out of here stays a task.
3181
3274
  const isTasksPath = (x) => x === '/tasks' || x === '/tasks/' || String(x || '').startsWith('/tasks/');
3182
3275
  if (isTasksPath(p) || isTasksPath(to)) {
3183
- if (['write', 'edit', 'mv', 'rm'].includes(action)) {
3276
+ if (['write', 'edit', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
3184
3277
  // Project-less: the org cannot be guessed (same guard as /wiki and /skills).
3185
3278
  await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3186
3279
  }
@@ -3192,7 +3285,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3192
3285
  // `fs(ls, path="/tasks")` creating one would put a real "Tasks" project in
3193
3286
  // an org the agent never named (DRAFT-36: org is an address, not a guess).
3194
3287
  // Writes are guarded above and may create; reads say "nothing here yet".
3195
- const readOnly = !['write', 'edit', 'mv', 'rm'].includes(action);
3288
+ const readOnly = !['write', 'edit', 'mv', 'rm', 'link', 'unlink'].includes(action);
3196
3289
  const sys = await api('POST', '/api/tasks/project', readOnly ? { create: false } : {}, orgHeader)
3197
3290
  .catch((e) => (readOnly && e.status === 404 ? null : Promise.reject(e)));
3198
3291
  if (!sys?.id) {
@@ -3479,6 +3572,51 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3479
3572
  const result = await api('DELETE', `/api/fs/${rmPath.replace(/^\//, '')}`, undefined, orgHeader);
3480
3573
  return ok(result);
3481
3574
  }
3575
+ case 'link':
3576
+ case 'unlink': {
3577
+ if (!filePath) return err(new Error(`${action} requires a source file path: /projects/<project>/<layer>/<lane>/<file>`));
3578
+ const from = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
3579
+ const body = { from };
3580
+ if (args.url) {
3581
+ body.url = args.url;
3582
+ } else if (to) {
3583
+ // Same destination grammar as mv: /projects/<ref>/<layer>/... is a
3584
+ // frame in another project, /projects/<ref> alone is the project
3585
+ // itself (the "this task belongs to that project" edge), and a bare
3586
+ // /<layer>/... is a frame in this one.
3587
+ const toClean = String(to).replace(/^\/+|\/+$/g, '');
3588
+ const toParts = toClean.replace(/^projects\/?/, '').split('/').filter(Boolean);
3589
+ if (toClean.startsWith('projects/') && toParts.length === 1) {
3590
+ const toMeta = await resolveProjectRef(toParts[0]).catch(() => null);
3591
+ if (!toMeta?.id) return err(new Error(`project not found: ${toParts[0]}`));
3592
+ body.toType = 'project';
3593
+ body.toId = toMeta.id;
3594
+ } else if (toClean.startsWith('projects/') && toParts.length >= 2) {
3595
+ const toRef = toParts[0];
3596
+ const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
3597
+ if (!sameProject) {
3598
+ const toMeta = await resolveProjectRef(toRef).catch(() => null);
3599
+ if (!toMeta?.id) return err(new Error(`project not found: ${toRef}`));
3600
+ body.toProjectId = toMeta.id;
3601
+ }
3602
+ body.to = '/' + toParts.slice(1).join('/');
3603
+ } else {
3604
+ body.to = toParts.length ? '/' + toParts.join('/') : String(to);
3605
+ }
3606
+ } else {
3607
+ return err(new Error(`${action} needs a target: to="<frame or project path>" or url="https://..."`));
3608
+ }
3609
+ return ok(await api('POST', `/api/fs/${action}`, body, orgHeader));
3610
+ }
3611
+ case 'links': {
3612
+ // On a file: its edges plus its (access-filtered) backlinks. On a
3613
+ // project path: the tasks linked to that project.
3614
+ if (filePath) {
3615
+ const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
3616
+ return ok(await api('GET', `/api/fs/links?path=${encodeURIComponent(relPath)}`, undefined, orgHeader));
3617
+ }
3618
+ return ok(await api('GET', '/api/fs/linked-tasks', undefined, orgHeader));
3619
+ }
3482
3620
  case 'search': {
3483
3621
  // /api/fs/search has never existed — this 404'd on every call (no fs
3484
3622
  // route matches a single `/search` segment). /api/search is the real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.6",
3
+ "version": "1.19.8",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [