drafted 1.19.4 → 1.19.6

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/install-mcp.sh CHANGED
@@ -480,7 +480,7 @@ load_global_agent_instructions() {
480
480
  fi
481
481
  cat <<'DRAFTED_GLOBAL_INSTRUCTIONS'
482
482
  <drafted>
483
- You have Drafted MCP tools — a shared surface for durable, reviewable work: produce substantive output as frames on the surface (not only in chat), put knowledge in the org wiki, and encode repeatable methods as skills. The full operating manual is the `drafted` skill installed with the plugin — follow it when working with Drafted. Before writing, verify the org/project echoed in the response is the one you intend.
483
+ You have Drafted MCP tools — a shared surface for durable, reviewable work: produce substantive output as frames on the surface (not only in chat), put knowledge in the org wiki, and encode repeatable methods as skills. The full operating manual is the `drafted` skill installed with the plugin — follow it when working with Drafted. Address orgs by path: `fs(ls, path="/")` lists them, and `/o/<org>/<root>/...` (root ∈ wiki, skills, projects) is the canonical path form — the org is part of the path, never a separate switch. Before writing, verify the org/project echoed in the response is the one you intend.
484
484
  </drafted>
485
485
  DRAFTED_GLOBAL_INSTRUCTIONS
486
486
  }
package/mcp/server.mjs CHANGED
@@ -257,7 +257,7 @@ export function splitOrgScope(raw) {
257
257
  if (!/^\/o\//.test(raw || '')) return { path: raw, org: null };
258
258
  const m = String(raw).match(/^\/o\/([^/]+)(?:\/(.*))?$/);
259
259
  if (!m || !m[1]) {
260
- return { error: `Invalid org-scoped path: ${raw} — expected /o/<org>/<root>/... where <root> is wiki, skills, or projects` };
260
+ return { error: `Invalid org-scoped path: ${raw} — expected /o/<org>/<root>/... where <root> is wiki, skills, tasks, or projects` };
261
261
  }
262
262
  return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
263
263
  }
@@ -384,7 +384,7 @@ 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 and the installed MCP version/update status (cached ~5min). 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 — 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.
@@ -2261,17 +2261,37 @@ async function sessionSurfaceBlock() {
2261
2261
  };
2262
2262
  }
2263
2263
 
2264
+ // Is the working org's Drive connected? Agents are told to prefer Google Workspace frames
2265
+ // when it is, so they need a way to ASK — this rode on `get_org` until that tool was retired
2266
+ // (DRAFT-36), leaving agents to probe by attempting a .google-doc write and reading the 400.
2267
+ // Rides on whoami, the once-per-session bootstrap that replaced it. Never throws: an
2268
+ // unreachable/erroring status is reported as not-connected, same as no connection.
2269
+ async function getGoogleDriveAvailability() {
2270
+ try {
2271
+ const status = await api('GET', '/api/google/status');
2272
+ return {
2273
+ connected: !!status?.connected,
2274
+ driveRootFolderName: status?.driveRootFolderName || null,
2275
+ preference: status?.connected
2276
+ ? 'Google Drive is connected for this org — strongly prefer Google Workspace frames (.google-doc / .google-sheet / .google-slide) for docs, sheets, and decks.'
2277
+ : 'Google Drive is not connected for this org — use normal Drafted frames (.md / .html); Google Workspace writes will fail.',
2278
+ };
2279
+ } catch {
2280
+ return { connected: false, driveRootFolderName: null, preference: 'Google Drive status unavailable — use normal Drafted frames.' };
2281
+ }
2282
+ }
2283
+
2264
2284
  // Identity + server health: report THIS agent session's own surface identity, server
2265
2285
  // reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
2266
2286
  // cached (5min), so repeat `whoami` calls are free; the server-side update gate still
2267
2287
  // blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
2268
- 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 and the installed MCP version/update status (cached ~5min). Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
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 () => {
2269
2289
  try {
2270
2290
  // Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
2271
2291
  // fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
2272
2292
  await ensureSession();
2273
2293
  const block = await sessionSurfaceBlock();
2274
- const mcpUpdate = await getCachedMcpUpdateMetadata();
2294
+ const [mcpUpdate, googleDrive] = await Promise.all([getCachedMcpUpdateMetadata(), getGoogleDriveAvailability()]);
2275
2295
  // Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
2276
2296
  // enough; without an explicit instruction agents rarely say which session they are, so users
2277
2297
  // can't match them to their tab on the Drafted surface.
@@ -2287,6 +2307,7 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
2287
2307
  mcpUpdate,
2288
2308
  editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
2289
2309
  agentLabel: getAgentLabel(),
2310
+ googleDrive,
2290
2311
  ...block,
2291
2312
  ...(instruction ? { instruction } : {}),
2292
2313
  });
@@ -2793,6 +2814,7 @@ server.resource('info', 'drafted://info', {
2793
2814
  roots: {
2794
2815
  wiki: '/o/<org>/wiki/<path>',
2795
2816
  skills: '/o/<org>/skills/<slug>',
2817
+ tasks: '/o/<org>/tasks/<lane?>/<file>',
2796
2818
  projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
2797
2819
  },
2798
2820
  tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'tour', 'screenshot', 'minion'],
@@ -2801,9 +2823,9 @@ server.resource('info', 'drafted://info', {
2801
2823
  };
2802
2824
  });
2803
2825
 
2804
- 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>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/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.', {
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.', {
2805
2827
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
2806
- path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/projects/... (bare /wiki, /skills, /projects also work; for mv: source)'),
2828
+ 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)'),
2807
2829
  to: z.string().optional().describe('[mv] destination path'),
2808
2830
  query: z.string().optional().describe('[search] term to match against names/content'),
2809
2831
  content: z.string().optional().describe('[write] inline HTML/markdown/text'),
@@ -2910,12 +2932,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2910
2932
 
2911
2933
  return ok(`Search "${q}"${orgFromPath ? ` in /o/${orgFromPath}` : ''}\n\n${out.join('\n\n')}`);
2912
2934
  }
2913
- if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, or /o/<org>/projects'));
2935
+ if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, /o/<org>/tasks, or /o/<org>/projects'));
2914
2936
  if (orgFromPath) {
2915
- // ls /o/<org> → that org's three roots
2937
+ // ls /o/<org> → that org's roots
2916
2938
  return ok([
2917
2939
  { name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
2918
2940
  { name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
2941
+ { name: 'tasks', type: 'directory', path: `/o/${orgFromPath}/tasks`, hint: 'work items — a task is a frame; status/assignee are front matter on read' },
2919
2942
  { name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
2920
2943
  ]);
2921
2944
  }
@@ -3149,6 +3172,44 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3149
3172
  }
3150
3173
  }
3151
3174
 
3175
+ // ── Root: /tasks/... ───────────────────────────────────────────
3176
+ // /tasks is an ADDRESS, not a fourth store. It resolves to a per-org system
3177
+ // project holding a single `tasks` layer, so every verb below is the ordinary
3178
+ // /projects machinery and a task frame has a real project id and a real path.
3179
+ // status/assignee live in COLUMNS (projected as front matter by read/write),
3180
+ // so a task moved out of here stays a task.
3181
+ const isTasksPath = (x) => x === '/tasks' || x === '/tasks/' || String(x || '').startsWith('/tasks/');
3182
+ if (isTasksPath(p) || isTasksPath(to)) {
3183
+ if (['write', 'edit', 'mv', 'rm'].includes(action)) {
3184
+ // Project-less: the org cannot be guessed (same guard as /wiki and /skills).
3185
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3186
+ }
3187
+ if ((action === 'rm' || action === 'mv') && (p === '/tasks' || p === '/tasks/')) {
3188
+ return err(new Error('/tasks is the org task root — it cannot be moved or deleted. Address a task: /o/<org>/tasks/<file> or /o/<org>/tasks/<lane>/<file>'));
3189
+ }
3190
+ // A READ never creates. Resolving the tasks root is a project create, and on
3191
+ // an unbound multi-org session the org here is an inherited cursor — so
3192
+ // `fs(ls, path="/tasks")` creating one would put a real "Tasks" project in
3193
+ // an org the agent never named (DRAFT-36: org is an address, not a guess).
3194
+ // Writes are guarded above and may create; reads say "nothing here yet".
3195
+ const readOnly = !['write', 'edit', 'mv', 'rm'].includes(action);
3196
+ const sys = await api('POST', '/api/tasks/project', readOnly ? { create: false } : {}, orgHeader)
3197
+ .catch((e) => (readOnly && e.status === 404 ? null : Promise.reject(e)));
3198
+ if (!sys?.id) {
3199
+ if (action === 'ls' || action === 'search') {
3200
+ return ok({ path: p, entries: [], totalAvailable: 0, note: 'This org has no tasks yet — writing a task under /tasks creates the root.' });
3201
+ }
3202
+ if (readOnly) return err(new Error(`Not found: ${p} — this org has no tasks yet.`));
3203
+ return err(new Error('could not resolve the tasks project for this org'));
3204
+ }
3205
+ // Seed the cache by id so the /projects branch resolves it without a lookup
3206
+ // (its /api/projects listing is not org-scoped).
3207
+ projectRefCache.set(sys.id, { id: sys.id, slug: sys.slug, name: sys.name, orgId: sys.orgId, orgSlug: sys.orgSlug });
3208
+ const rewrite = (x) => `/projects/${sys.id}/tasks${x === '/tasks' || x === '/tasks/' ? '' : x.slice('/tasks'.length)}`;
3209
+ if (isTasksPath(p)) p = rewrite(p);
3210
+ if (isTasksPath(to)) to = rewrite(to);
3211
+ }
3212
+
3152
3213
  // ── Root: /projects/... ────────────────────────────────────────
3153
3214
  if (p.startsWith('/projects')) {
3154
3215
  const parts = p.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
@@ -3471,7 +3532,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3471
3532
  return meta ? withProjectOverride(meta, run) : run();
3472
3533
  }
3473
3534
 
3474
- return err(new Error(`unknown fs path: ${rawPath} — expected /o/<org>/wiki/..., /o/<org>/skills/..., or /o/<org>/projects/... (bare /wiki, /skills, /projects still work)`));
3535
+ return err(new Error(`unknown fs path: ${rawPath} — expected /o/<org>/wiki/..., /o/<org>/skills/..., /o/<org>/tasks/..., or /o/<org>/projects/... (bare /wiki, /skills, /tasks, /projects still work)`));
3475
3536
  });
3476
3537
 
3477
3538
  // ── repo tool: registered git repos (org substrate, Phase 4) ──────────
@@ -153,6 +153,8 @@ assert.deepEqual(splitOrgScope('/o/acme/projects/design/beoflow/wireframes/x.htm
153
153
  assert.deepEqual(splitOrgScope('/o/acme'), { path: '/', org: 'acme' }, 'ls /o/<org> lists that org\'s roots');
154
154
  assert.deepEqual(splitOrgScope('/o/acme/'), { path: '/', org: 'acme' }, 'trailing slash on the org root is harmless');
155
155
  assert.deepEqual(splitOrgScope('/o/acme%20brand/skills'), { path: '/skills', org: 'acme brand' }, 'org segment is URL-decoded');
156
+ assert.deepEqual(splitOrgScope('/o/acme/tasks'), { path: '/tasks', org: 'acme' }, 'the tasks root is a root like any other');
157
+ assert.deepEqual(splitOrgScope('/o/acme/tasks/sprint-1/ship-it.md'), { path: '/tasks/sprint-1/ship-it.md', org: 'acme' }, 'a lane + file under /tasks strips to the bare root + org');
156
158
  assert.deepEqual(splitOrgScope('/wiki/engineering'), { path: '/wiki/engineering', org: null }, 'bare root paths pass through untouched');
157
159
  assert.deepEqual(splitOrgScope('/projects/x/y/z.html'), { path: '/projects/x/y/z.html', org: null }, 'bare project paths pass through untouched');
158
160
  assert.ok(splitOrgScope('/o/').error, 'a bare /o/ is an invalid org-scoped path');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.4",
3
+ "version": "1.19.6",
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": [
@@ -65,7 +65,7 @@ These bookend the loop. Prime/feed at the start, deposit at the end.
65
65
  - **Create a project with `fs(mkdir, path="/o/<org>/projects/<name>")`** — or just `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/<file>", content=...)` and the project + layer auto-create in the addressed org.
66
66
  - **Default to the surface for substantive artifacts.** When asked to draft, write, plan, analyze, compare, design, document, summarize, report, spec, model, or make a deck/table, create or update frames instead of leaving the durable result only in chat. One visible frame per artifact or section.
67
67
  - **Read before editing.** `fs(read)` returns every line hashline-annotated (`1abc|<content>`); `fs(edit, ops=[{type:"replace", lineHash:"1abc", newContent:"..."}])` targets exactly that line. For partial reads, pass `lines: "2-50"` — you get back just that range, still hash-annotated, and can edit within it.
68
- - **Prefer Google Workspace when Drive is connected.** Use `fs(write, path=".../<name>.google-doc"|".google-sheet"|".google-slide")` for docs, sheets, and decks; populate immediately with the matching native write action.
68
+ - **Prefer Google Workspace when Drive is connected.** `whoami` reports it — `googleDrive.connected: true` means use `fs(write, path=".../<name>.google-doc"|".google-sheet"|".google-slide")` for docs, sheets, and decks, populated immediately with the matching native write action. False means they cannot be created at all: use `.md`/`.html` frames and don't ask the user which fallback they want.
69
69
  - **`fs(mv, from="/o/<org>/projects/<p>/<layer>/<lane>/<file>", to="...")`** renames or moves (cross-project too). **`fs(rm, path="/o/<org>/projects/<project>")` archives** — agents never hard-delete; the archive is in the web UI.
70
70
  - **Return a clickable link** for what you touched — the `frameUrl`/`projectUrl` in the fs response is the URL the user opens.
71
71