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.
- package/mcp/server.mjs +154 -16
- 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
|
-
|
|
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', '
|
|
2321
|
-
action: z.enum(['name']).describe('Operation
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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": [
|