drafted 1.16.0 → 1.17.0

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 CHANGED
@@ -140,10 +140,7 @@ const LOCAL_ONLY_PARAMS = ['file_path'];
140
140
  // tool wrapper before the handler runs — so the stdio contract, the Zod runtime
141
141
  // validation, and every handler stay byte-for-byte unchanged.
142
142
  const REMOTE_JSON_STRING_PARAMS = {
143
- frame: ['excalidraw_data', 'elements', 'requests', 'state'],
144
- wiki: ['frontmatter', 'pages'],
145
- project: ['layers'],
146
- template: ['layers'],
143
+ fs: ['ops', 'state'],
147
144
  minion: ['target', 'checklist', 'output'],
148
145
  trigger: ['payload'],
149
146
  };
@@ -217,6 +214,35 @@ export function receiptOrg({ resourceOrgId, sessionOrg, orgList }) {
217
214
  return found || { id: rid, name: null };
218
215
  }
219
216
 
217
+ // ── Org-scoped path grammar (Shape A: org is the top folder) ────
218
+ // An org is the top of the filesystem: /o/<org>/<root>/... where <root> is
219
+ // wiki | skills | projects. The org is part of the PATH — the agent ADDRESSES an
220
+ // org, it never switches to one — and drives the per-request X-Drafted-Org, so
221
+ // concurrent agents in different orgs cannot affect each other: there is no
222
+ // shared org cursor to clobber. Bare roots (/wiki, /skills, /projects) stay
223
+ // accepted and resolve via the session\'s working org (backward compat).
224
+ // Pure + exported so the grammar is assertable (mcp/test-org-guards.mjs).
225
+ export function splitOrgScope(raw) {
226
+ if (!/^\/o\//.test(raw || '')) return { path: raw, org: null };
227
+ const m = String(raw).match(/^\/o\/([^/]+)(?:\/(.*))?$/);
228
+ if (!m || !m[1]) {
229
+ return { error: `Invalid org-scoped path: ${raw} — expected /o/<org>/<root>/... where <root> is wiki, skills, or projects` };
230
+ }
231
+ return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
232
+ }
233
+
234
+ // Accept a FULL share URL (https://drafted.live/o/...?...) — humans paste links,
235
+ // not paths. The pathname of a Drafted URL IS the fs path (Q2: one canonical link
236
+ // for both consumers), so stripping the origin leaves an addressable path. Falls
237
+ // back to the input unchanged when it isn't a URL. Pure + exported (asserted in
238
+ // mcp/test-org-guards.mjs).
239
+ export function stripUrlOrigin(p) {
240
+ const s = String(p || '');
241
+ if (!/^https?:\/\//i.test(s)) return s;
242
+ try { return new URL(s).pathname; } catch { return s; }
243
+ }
244
+
245
+
220
246
  export function createMcpServer(transport) {
221
247
  // Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
222
248
  // server, not the user's machine, so local-filesystem params like `file_path`
@@ -234,24 +260,24 @@ const server = new McpServer({
234
260
  version: PACKAGE_VERSION,
235
261
  description: `Multi-tenant design workspace. Structure: Organization → Projects → Layers → Lanes → Frames.
236
262
 
237
- An org contains projects. Each project has a zoomable canvas with frames (HTML files) organized as /{layer}/{lane}/{filename}. Layers are predefined categories (wireframes, designs, brand-assets, etc.), lanes are groups within a layer, and frames are the individual design files.
263
+ Drafted is navigated like a local filesystem — one tool (fs), one path grammar, org first: fs(ls, path="/") lists the orgs you can address, then /o/<org>/wiki/<path> (org knowledge pages), /o/<org>/skills/<slug> (reusable procedures), /o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file> (frames on a zoomable canvas). The project is resolved from the path itself no separate "open" step, and the org is part of the path no org switching.
238
264
 
239
- WORKFLOW: project(action="list") → project(action="open") → ls / → read/write/edit. Projects span all orgs -- opening a project binds this agent session's context, and the org derives from the project. Every response includes a "project" field showing which project you're operating on -- always verify it matches your intent before writing. There is no org switching: address resources by UUID (org self-derives) and pass org=... on creates/searches that name no resource (wiki write, skill add, project create).
265
+ WORKFLOW: fs(ls, path="/") → fs(ls, path="/o/<org>/projects") → fs(read/write/edit, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>"). Address resources by UUID (org self-derives) and write wiki/skills under /o/<org>/... so the org is explicit. (Bare /wiki, /skills, /projects roots still resolve via the session\'s working org.)
240
266
 
241
- SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKILL.md files. When a user says "use the X skill", call skill(action="search") to find it, then skill(action="load") to get its instructions. Skills can cover anything: UX guidelines, copywriting rules, brand voice, coding standards, review checklists, etc.
267
+ SKILLS: /skills/<slug> holds reusable agent instructions (SKILL.md files). When a user says "use the X skill", fs(ls, path="/skills") then fs(read, path="/skills/<slug>"). Skills can cover anything: UX guidelines, copywriting rules, brand voice, coding standards, review checklists, etc.
242
268
 
243
- BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via read(<frameId>). One line per related frame. Skip for throwaway or exploratory frames.
269
+ BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via fs(read, path="/f/<frameId>"). One line per related frame. Skip for throwaway or exploratory frames.
244
270
 
245
271
  CONTEXT RULES (follow these before every action):
246
- - WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
272
+ - WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use fs(search, query="<terms>") with the /wiki root.
247
273
  - LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
248
- IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
274
+ IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use fs(read, path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
249
275
 
250
276
  LINKING: link the user to the narrowest thing you touched, never the project as a stand-in. Wrote or edited ONE frame → give its \`frameUrl\` (from the write/read/ls response). Touched a whole lane → its \`laneUrl\`; a whole layer → its \`layerUrl\` (both on the matching \`ls\` entries). Only when the work spans the project is the project URL the right link. A project link where a frame link was available makes the user hunt the surface for what you just did.`,
251
277
  }, {
252
278
  // Initialize instructions: the agent-identity contract, so an agent learns its own
253
279
  // tab name + the right way to read it WITHOUT having to "think to" call a tool.
254
- instructions: `SESSION IDENTITY — read this first: you run as a NAMED session tab visible to the user on their Drafted surface. Your session has a human-readable name (a Greek term, e.g. "Nous") — that name is how the user matches YOUR window to the tab they see, so identify yourself by it when it matters which agent you are. Read it from get_org (response field "session.name") or from the whoami tool; call whoami to refresh after a reconnect.
280
+ instructions: `SESSION IDENTITY — read this first: you run as a NAMED session tab visible to the user on their Drafted surface. Your session name is how the user matches YOUR window to the tab they see. NAME YOURSELF on first connect: call whoami first — if it reports nameRequired, all other tools are gated until you set a short 2-3 word name describing the work with session(action="name", name="...") (whoami suggests one from your working directory). The name persists across reconnects, so you only set it once. Read your name from the whoami tool; call whoami to refresh after a reconnect.
255
281
 
256
282
  DUAL REGISTRATION IS NORMAL: a separate "Drafted" remote connector (https://drafted.live/mcp, managed by claude.ai) may appear alongside this local stdio server in MCP listings. It is NOT a duplicate and NOT broken — it serves claude.ai web/mobile/Cowork, where a local stdio process cannot run. Never advise removing it; a "needs authentication" state on it is fixed by signing in from claude.ai and does not affect this stdio session.${isRemote ? `
257
283
 
@@ -275,76 +301,31 @@ const TOOL_ANNOTATIONS = {
275
301
  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.' },
276
302
 
277
303
  // Identity — read-only introspection of THIS agent's session
278
- 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. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
279
-
280
- // Health — call once per session, right after whoami and before any real work
281
- health: { title: 'Server health', readOnlyHint: true, destructiveHint: false, openWorldHint: true, description: 'Server reachability + installed MCP version/update status. Call this once per session, right after whoami and before doing any real work, so a required update surfaces before you act on stale tool behavior.' },
282
-
283
- // Projects
284
- project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (bind this agent session to a project — org derives from it), create (org= names where it is born), update, move to another org.' },
285
- get_org: { title: 'Organization', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'List your orgs, the default org, and Google Drive availability (action="get", default), or fetch installed MCP update instructions (action="update_mcp"). There is no org switching — org derives from the resource you address; creates/searches take org=. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
304
+ 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.' },
286
305
 
287
- // Templates
288
- template: { title: 'Templates', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage project templates: list, create, update, delete, fork.' },
289
-
290
- // Layers
291
- layer: { title: 'Layers', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage layers in a project: add, update, remove, reorder.' },
292
-
293
- // Frames — filesystem
294
- ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
295
- frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: true, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, search, restore frame versions, create Google Workspace frames, or write values into Google Sheet frames in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
296
- rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
297
- // batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
306
+ // Session naming — the name-before-work gate: every agent session must set a short
307
+ // name describing the work before any other tool call succeeds.
308
+ 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).' },
298
309
 
299
310
  // Canvas / view
300
311
  focus: { title: 'Focus on target', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Pan the canvas viewport for connected clients to a frame, lane, or layer.' },
301
312
  screenshot: { title: 'Screenshot', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Render a PNG via headless browser. `scope=frame` for a single frame, `scope=canvas` for a region of the project surface.' },
302
313
 
303
- // Assets
304
- asset: { title: 'Assets', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Manage project assets (CSS/JS/images/fonts referenced by frames). `action=upload` or `action=list`.' },
305
-
306
- // Connectors / layout
307
- connector: { title: 'Connectors', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Connect or disconnect frames with arrows on the surface. `action=connect` or `action=disconnect`.' },
308
- layout: { title: 'Auto-layout', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
309
-
310
- // Skills
311
- skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
312
- wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
313
-
314
314
  // Minions — checklist-driven intake surfaces bound to a project
315
- 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 then write a producible into the project. Dispatch by `action`: meta (discover layers/lanes/frames), 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) and verify it produces the right Doc/Sheet output. Requires the agent allowlist.' },
316
- 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, it is 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.' },
315
+ 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.' },
316
+ 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.' },
317
+ 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.' },
317
318
  };
318
319
 
319
320
  function isMutatingToolCall(name, args = {}) {
320
321
  const action = args?.action;
321
322
  switch (name) {
322
- case 'project':
323
- return ['create', 'update', 'move'].includes(action);
324
- case 'template':
325
- return ['create', 'update', 'delete', 'fork'].includes(action);
326
- case 'layer':
327
- return ['add', 'update', 'remove', 'reorder'].includes(action);
328
- case 'frame':
329
- return ![
330
- 'read', 'search', 'versions', 'read_version',
331
- 'get_sheet', 'read_sheet_values',
332
- 'get_doc', 'read_doc_content',
333
- 'get_slide', 'read_slide_content',
334
- 'get_excel', 'read_excel_range',
335
- ].includes(action);
336
- case 'asset':
337
- return ['upload', 'rm'].includes(action);
338
- case 'skill':
339
- return ['add', 'update', 'remove', 'attach', 'detach', 'favorite', 'unfavorite', 'update_file'].includes(action);
340
- case 'wiki':
341
- return ['log', 'write', 'edit', 'mv', 'rm', 'source-register', 'bulk-write'].includes(action);
342
323
  case 'minion':
343
324
  return ['create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve'].includes(action);
344
- case 'rm':
345
- case 'connector':
346
- case 'layout':
347
- return true;
325
+ case 'trigger':
326
+ return ['create', 'update', 'rotate', 'delete'].includes(action);
327
+ case 'fs':
328
+ return !['ls', 'read', 'search'].includes(action);
348
329
  default:
349
330
  return false;
350
331
  }
@@ -1109,7 +1090,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
1109
1090
  if (sess._stdio) savePersistedProject(null, { serverUrl: getServerUrl() });
1110
1091
  throw new Error(
1111
1092
  `${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
1112
- `Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
1093
+ `Active project cleared. Address an /o/<org>/projects/<project>/... path or fs(ls, path="/o/<org>/projects") to bind one; org-scoped wiki/skills (fs /o/<org>/wiki, fs /o/<org>/skills) work without it.`
1113
1094
  );
1114
1095
  }
1115
1096
  // Attach the structured body so callers can key off machine-readable fields
@@ -1117,6 +1098,13 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
1117
1098
  const apiErr = new Error(msg);
1118
1099
  apiErr.status = res.status;
1119
1100
  if (data && typeof data === 'object') { apiErr.code = data.code; apiErr.body = data; }
1101
+ // Name-before-work gate: enrich the server message with the LOCAL working-directory
1102
+ // suggestion (the server doesn't know cwd) so the agent can accept it in one call
1103
+ // even if it never ran whoami.
1104
+ if (apiErr.code === 'session_unnamed') {
1105
+ const suggest = getSuggestedSessionName();
1106
+ if (suggest) apiErr.message += ` Suggested from your working directory: "${suggest}" — accept it with session(action="name", name="${suggest}") or pick your own 2-3 word name.`;
1107
+ }
1120
1108
  throw apiErr;
1121
1109
  }
1122
1110
  return data;
@@ -1184,8 +1172,8 @@ function shapeSkillCatalog(result, { limit, offset = 0, compact = false } = {})
1184
1172
  result.truncated = start + page.length < total;
1185
1173
  result.skills = page.map(compact ? compactSkillEntry : summarizeSkillForSearch);
1186
1174
  result.note = compact
1187
- ? 'Compact catalog: {slug,name,tags} only. Load full content with skill(action="load", skill="<slug>"). Pass compact=false for descriptions/triggerPatterns; use offset to page.'
1188
- : 'Search/list returns skill summaries only (no SKILL.md body). Use skill(action="load", skill="<slug-or-id>") for full content. Pass compact=true for a leaner {slug,name,tags} catalog; use limit/offset to page.';
1175
+ ? 'Compact catalog: {slug,name,tags} only. Load full content with fs(read, path="/skills/<slug>"). Pass compact=false for descriptions/triggerPatterns; use offset to page.'
1176
+ : 'Search/list returns skill summaries only (no SKILL.md body). Use fs(read, path="/skills/<slug-or-id>") for full content. Pass compact=true for a leaner {slug,name,tags} catalog; use limit/offset to page.';
1189
1177
  return result;
1190
1178
  }
1191
1179
 
@@ -1319,6 +1307,20 @@ function setMcpActiveProject(projectId, meta = null) {
1319
1307
  // ponytail: process-lifetime cache, no TTL — a project rename goes stale until restart.
1320
1308
  // Add invalidation if renames ever become common.
1321
1309
  const projectRefCache = new Map();
1310
+
1311
+ // Does an org-scoped path's org segment (/o/<org>/...) address the org of a
1312
+ // resolved project? Accepts the org id, slug, or name. Rejects paths whose org
1313
+ // segment contradicts the project's row org — the silent-strip gap in the old
1314
+ // URL-form alias, where /o/<orgX>/projects/<projInOrgY>/... wrote to orgY as if
1315
+ // orgX had been addressed.
1316
+ async function pathOrgMatches(orgSegment, meta) {
1317
+ if (meta?.orgId && orgSegment === meta.orgId) return true;
1318
+ if (meta?.orgSlug && orgSegment === meta.orgSlug) return true;
1319
+ const orgs = await getOrgList();
1320
+ const hit = orgs.find(o => o.id === orgSegment || (o.slug && o.slug === orgSegment) || (o.name || '').toLowerCase() === String(orgSegment).toLowerCase());
1321
+ return !!hit && meta?.orgId === hit.id;
1322
+ }
1323
+
1322
1324
  async function resolveProjectRef(ref) {
1323
1325
  if (!ref) return null;
1324
1326
  const hit = projectRefCache.get(ref);
@@ -1337,6 +1339,87 @@ async function resolveProjectRef(ref) {
1337
1339
  return meta;
1338
1340
  }
1339
1341
 
1342
+ /**
1343
+ * Resolve a project argument to its meta, accepting the pseudo-filesystem path
1344
+ * form (/projects/<name> or /projects/<folder>/<name>) OR a bare slug / name /
1345
+ * UUID — all through the same resolver, so every tool that names a project
1346
+ * behaves identically. Throws with a clear message when missing or unknown.
1347
+ */
1348
+ async function resolveProjectArg(projectArg, { required = true } = {}) {
1349
+ if (!projectArg) {
1350
+ if (!required) return null;
1351
+ throw new Error('project required — pass it as /projects/<name> (e.g. projectId="/projects/beoflow"), a slug, or a UUID');
1352
+ }
1353
+ let ref = String(projectArg);
1354
+ // Org-scoped path (/o/<org>/projects/<name>) and bare root form
1355
+ // (/projects/<name> or /projects/<folder>/<name>) → take the last segment as the ref.
1356
+ const scoped = splitOrgScope(ref);
1357
+ if (scoped.error) throw new Error(scoped.error);
1358
+ ref = scoped.path || '';
1359
+ if (ref.startsWith('/projects')) {
1360
+ const parts = ref.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
1361
+ if (parts.length === 0) throw new Error(`Invalid project path: ${projectArg}`);
1362
+ ref = parts[parts.length - 1];
1363
+ }
1364
+ const meta = await resolveProjectRef(ref);
1365
+ if (!meta) throw new Error(`Project not found: ${projectArg} — list projects with fs(ls, path="/projects")`);
1366
+ return meta;
1367
+ }
1368
+
1369
+ /**
1370
+ * Resolve a pseudo-filesystem path to a frame ID.
1371
+ * Accepts:
1372
+ * /projects/<project>/<layer>/<lane>/<file> (canonical)
1373
+ * /projects/<folder>/<project>/<layer>/<lane>/<file>
1374
+ * /projects/<project>/<layer>/<file> (layer-root file)
1375
+ * /o/<org>/projects/<project>/<layer>/<lane>/<file> (URL form)
1376
+ * /f/<uuid> | bare UUID
1377
+ * Returns { frameId } or throws with a helpful message.
1378
+ */
1379
+ async function resolveFsFramePath(path) {
1380
+ const urlMatch = String(path).match(/\/f\/([a-f0-9-]{36})/);
1381
+ const uuidMatch = String(path).match(/^[a-f0-9-]{36}$/);
1382
+ if (urlMatch?.[1] || uuidMatch) return { frameId: urlMatch?.[1] || uuidMatch[0] };
1383
+
1384
+ const p = stripUrlOrigin(String(path));
1385
+ // URL form: /o/<org>/projects/<project>/<layer>/<lane>/<file>
1386
+ let fsPath = p;
1387
+ const urlForm = p.match(/^\/o\/[^/]+\/projects\/(.+)$/);
1388
+ if (urlForm) fsPath = '/projects/' + urlForm[1];
1389
+ if (!fsPath.startsWith('/projects')) {
1390
+ throw new Error('Target must be a frame URL, frame ID, or path /projects/<project>/<layer>/<lane>/<file>');
1391
+ }
1392
+
1393
+ const parts = fsPath.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
1394
+ if (parts.length < 3) throw new Error('Path must include the project, layer, and file: /projects/<project>/<layer>/<lane>/<file>');
1395
+ // <project>/<layer>/<lane>/<file> (no folder) or <folder>/<project>/<layer>/<lane>/<file>
1396
+ const projectRef = parts.length === 4 ? parts[0] : parts.length >= 5 ? parts[1] : parts[0];
1397
+ let layer, lane = null, filename;
1398
+ if (parts.length >= 4) {
1399
+ // canonical: .../<layer>/<lane>/<file>
1400
+ layer = parts[parts.length - 3];
1401
+ lane = parts[parts.length - 2];
1402
+ filename = parts[parts.length - 1];
1403
+ } else {
1404
+ // 3 segments after project: <layer>/<file> (layer-root) or <layer>/<lane> (lane view).
1405
+ // A filename has an extension; a lane name usually doesn't.
1406
+ layer = parts[1];
1407
+ if (/\.[a-z0-9]+$/i.test(parts[2])) {
1408
+ filename = parts[2]; // layer-root file
1409
+ } else {
1410
+ throw new Error('Path must include a file: /projects/<project>/<layer>/<lane>/<file>');
1411
+ }
1412
+ }
1413
+ const meta = await resolveProjectRef(projectRef);
1414
+ if (!meta) throw new Error(`Project not found: ${projectRef}`);
1415
+ const filePath = lane
1416
+ ? `${encodeURIComponent(layer)}/${encodeURIComponent(lane)}/${encodeURIComponent(filename)}`
1417
+ : `${encodeURIComponent(layer)}/${encodeURIComponent(filename)}`;
1418
+ const frame = await withProjectOverride(meta, () => api('GET', `/api/fs/${filePath}`));
1419
+ if (!frame?.id) throw new Error(`Frame not found: ${path}`);
1420
+ return { frameId: frame.id };
1421
+ }
1422
+
1340
1423
  // Run one tool call against a caller-named project WITHOUT rebinding the session.
1341
1424
  // The override lives in this call's AsyncLocalStorage frame only — request-local, so
1342
1425
  // it satisfies the DRAFT-36 addressing invariant (no shared, another-session-writable
@@ -1353,12 +1436,12 @@ function withProjectOverride(meta, fn) {
1353
1436
 
1354
1437
  // Tools that address frames/assets/layout inside ONE project. These accept the
1355
1438
  // per-call `projectId` override; everything else resolves org-side.
1356
- const PROJECT_SCOPED_TOOLS = new Set(['frame', 'ls', 'rm', 'asset', 'connector', 'layout']);
1439
+ const PROJECT_SCOPED_TOOLS = new Set(['fs']);
1357
1440
 
1358
1441
  const PROJECT_OVERRIDE_PARAM = z.string().optional().describe(
1359
1442
  'Target project (UUID, slug, or name) for THIS call only — does NOT rebind the session. ' +
1360
1443
  'Use it when the session lost its binding (a reconnect), or to touch another project once ' +
1361
- 'without re-opening it. Omit to use the project bound by project(action="open").',
1444
+ 'without re-opening it. Omit to derive the project from the path (/projects/<project>/...).',
1362
1445
  );
1363
1446
 
1364
1447
  // Returns { id, slug, name, orgId } for the project this MCP session most
@@ -1396,7 +1479,9 @@ async function connectAgentWs() {
1396
1479
  console.error('[MCP-WS] Connected');
1397
1480
  // Announce presence immediately so the agent surfaces (greyed/idle, no project) the
1398
1481
  // moment it connects — before opening any project. Project work later flips it active.
1399
- try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel() })); } catch {}
1482
+ // suggestedName (basename of the working directory) lets the server offer it in the
1483
+ // name-before-work gate message and as the tab placeholder.
1484
+ try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel(), suggestedName: getSuggestedSessionName() })); } catch {}
1400
1485
  if (getState().projectId) {
1401
1486
  agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() }));
1402
1487
  }
@@ -1411,6 +1496,7 @@ async function connectAgentWs() {
1411
1496
  agentSurface = {
1412
1497
  sessionId: m.sessionId, userId: m.userId, orgId: m.orgId, projectId: m.projectId,
1413
1498
  name: m.name, capital: m.capital, color: m.color, alive: m.alive, surfaced: true,
1499
+ nameRequired: !!m.nameRequired, suggestedName: m.suggestedName || null,
1414
1500
  capturedAt: Date.now(),
1415
1501
  };
1416
1502
  }
@@ -1446,6 +1532,21 @@ function getAgentLabel() {
1446
1532
  return proj ? `${editor} - ${proj}` : editor;
1447
1533
  }
1448
1534
 
1535
+ // Suggested session name for the name-before-work gate: the user-set DRAFTED_AGENT_NAME
1536
+ // when present (it describes the work), else the basename of this process's working
1537
+ // directory — the durable session context Claude Code / Codex / pi all record per session
1538
+ // (their transcripts carry cwd; the MCP stdio process is spawned there). Terminal tabs
1539
+ // use the same convention, so the user recognizes it.
1540
+ function getSuggestedSessionName() {
1541
+ const explicit = (process.env.DRAFTED_AGENT_NAME || '').trim();
1542
+ if (explicit) return explicit;
1543
+ try {
1544
+ const base = basename(process.cwd());
1545
+ if (base && base !== '/') return base;
1546
+ } catch { /* fall through */ }
1547
+ return null;
1548
+ }
1549
+
1449
1550
  async function joinAgentWsRoom(projectId) {
1450
1551
  // Ensure WS is connected (may not be after restart/session re-clone)
1451
1552
  if (!agentWs || agentWs.readyState > WebSocket.OPEN) {
@@ -1461,12 +1562,12 @@ async function joinAgentWsRoom(projectId) {
1461
1562
  }
1462
1563
 
1463
1564
  // Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
1464
- // the first-substantive-action ping below so a bare whoami/health/auth never yanks the
1565
+ // the first-substantive-action ping below so a bare whoami/auth never yanks the
1465
1566
  // desktop app's window to the front.
1466
- const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', 'health', 'auth']);
1567
+ const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', 'auth']);
1467
1568
  let hasAnnouncedSubstantiveWork = false;
1468
1569
 
1469
- // Tell the server this agent has started real work (first call past whoami/health/auth this
1570
+ // Tell the server this agent has started real work (first call past whoami/auth this
1470
1571
  // process), so it can foreground the desktop app's window. Fire-and-forget — never blocks or
1471
1572
  // fails the tool call that triggered it.
1472
1573
  async function announceSubstantiveWork() {
@@ -1552,22 +1653,23 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1552
1653
  return;
1553
1654
  }
1554
1655
  if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
1555
- // Actionable, not a dead end: name the orgs and the ONE call that binds this session,
1556
- // so the agent recovers itself instead of stalling on the human. Project-less wiki and
1557
- // skill work is a first-class flow no project required, ever. The only thing refused
1558
- // is GUESSING which org, which is what silently misfiled a page into the user's
1559
- // default org. (Listing the orgs is safe: they're the caller's own memberships.)
1656
+ // Actionable, not a dead end: name the orgs and the address that resolves it.
1657
+ // Project-less wiki and skill work is a first-class flow no project required, ever.
1658
+ // The only thing refused is GUESSING which org, which is what silently misfiled a
1659
+ // page into the user's default org. (Listing the orgs is safe: they're the caller's
1660
+ // own memberships.) Shape A recovery: the org is the top folder address it in the
1661
+ // path, no binding call exists anymore (fs(ls, path="/", org=...) does NOT bind).
1560
1662
  const names = orgs.map(o => o.name || o.id).filter(Boolean);
1561
1663
  throw new Error(
1562
1664
  `Which org? A project-less wiki/skill write needs one, and you belong to ${orgs.length}: ` +
1563
1665
  `${names.join(', ')}. Don't guess — an unaddressed write lands in whichever org this ` +
1564
1666
  `session inherited, which is how a page meant for one org ends up in another.\n` +
1565
- `Recover with ONE call: get_org(action="use", org="<name>"). That binds this session's ` +
1566
- `working org every later project-less wiki/skill write then just works, with no project ` +
1567
- `and no org= to repeat. Prefer it over passing org= per call, which pays this toll on ` +
1568
- `every write.\n` +
1569
- `(Alternatives: org="<name>" on this single call; or project(action="open") if the work ` +
1570
- `belongs to a project — the org derives from it.)\n` +
1667
+ `Address the org in the PATH it's the top folder of the filesystem: ` +
1668
+ `fs(ls, path="/") lists your orgs, then write to /o/<org>/wiki/... or ` +
1669
+ `/o/<org>/skills/... the org is part of the path, and every later write to that ` +
1670
+ `path just works with no org= and no project.\n` +
1671
+ `(Alternatives: org="<name>" on this single call; or a /o/<org>/projects/<project>/... ` +
1672
+ `path — the org derives from the project.)\n` +
1571
1673
  `Pick from the conversation if the org is clear from context; ask the user only if it isn't.`
1572
1674
  );
1573
1675
  }
@@ -1582,9 +1684,15 @@ async function getOrgList() {
1582
1684
  if (sess.cachedOrgs && Date.now() - (sess.cachedOrgsTime || 0) < 30000) return sess.cachedOrgs;
1583
1685
  try {
1584
1686
  const d = await api('GET', '/api/orgs');
1585
- sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
1687
+ sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name, slug: o.slug || null, isActive: !!o.isActive }));
1586
1688
  sess.cachedOrgsTime = Date.now();
1587
- } catch { sess.cachedOrgs = sess.cachedOrgs || []; }
1689
+ } catch (e) {
1690
+ // The name-before-work gate must surface, not be swallowed by this defensive catch —
1691
+ // otherwise an unnamed agent sees "no orgs to list" instead of the gate that tells
1692
+ // it to name itself. Everything else degrades to [] ("can't determine membership").
1693
+ if (e?.code === 'session_unnamed') throw e;
1694
+ sess.cachedOrgs = sess.cachedOrgs || [];
1695
+ }
1588
1696
  return sess.cachedOrgs;
1589
1697
  }
1590
1698
 
@@ -1666,7 +1774,7 @@ async function checkOrgSkills(orgId, operation) {
1666
1774
  const loaded = getSessionState().loadedSkillIds;
1667
1775
  const unloaded = skills.filter(s => !loaded.has(s.id));
1668
1776
  if (unloaded.length === 0) return null;
1669
- const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
1777
+ const lines = unloaded.map(s => ` fs(read, path="/skills/${s.slug || s.id}") -- ${s.name}`);
1670
1778
  return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
1671
1779
  `Unloaded skills:\n${lines.join('\n')}\n\n` +
1672
1780
  `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
@@ -1800,7 +1908,11 @@ async function launchDesktopSignin() {
1800
1908
  const child = spawn(bin, ['--open-login'], {
1801
1909
  detached: true,
1802
1910
  stdio: 'ignore',
1803
- env: { ...process.env, DRAFTED_OPEN_LOGIN: '1' },
1911
+ // DRAFTED_OPEN_LOGIN=1 makes a fresh primary instance open sign-in on boot. The app's
1912
+ // base_url() reads DRAFTED_DESKTOP_URL (default https://drafted.live) — pass the server
1913
+ // this MCP is bound to, or a local install's sign-in window would target production and
1914
+ // the cookie→auth.json bridge would write the production file, never DRAFTED_AUTH_FILE.
1915
+ env: { ...process.env, DRAFTED_OPEN_LOGIN: '1', DRAFTED_DESKTOP_URL: getServerUrl() },
1804
1916
  });
1805
1917
  child.unref();
1806
1918
  return true;
@@ -1966,6 +2078,8 @@ async function sessionSurfaceBlock() {
1966
2078
  color: agentSurface.color,
1967
2079
  surfaced: true,
1968
2080
  alive: !!agentSurface.alive,
2081
+ nameRequired: !!agentSurface.nameRequired,
2082
+ suggestedName: agentSurface.suggestedName || getSuggestedSessionName(),
1969
2083
  // `alive` was ambiguous: agents couldn't tell whether it meant "your writes are
1970
2084
  // no-ops" (act differently) or "the user's canvas tab is closed" (ignore). It's the
1971
2085
  // latter — say so, so nobody changes behavior over it.
@@ -1974,7 +2088,8 @@ async function sessionSurfaceBlock() {
1974
2088
  : 'No canvas surface is currently open for this session. 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.',
1975
2089
  };
1976
2090
  }
1977
- // No WS ack yet — best-effort identity from /auth/me so callers still get a userId/org.
2091
+ // No WS ack yet — best-effort identity from /auth/me so callers still get a userId/org
2092
+ // AND the name-before-work gate state (the WS ack races the first tool call).
1978
2093
  const cookieSid = sessionId || getBootstrapSessionId();
1979
2094
  let me = null;
1980
2095
  let unreachable = false;
@@ -1992,13 +2107,16 @@ async function sessionSurfaceBlock() {
1992
2107
  if (String(e?.message || '').startsWith('TLS interception')) unreachableWhy = e.message;
1993
2108
  }
1994
2109
  }
2110
+ const nameRequired = !!me?.agentClone && !me?.surfaceName;
1995
2111
  return {
1996
2112
  sessionId: cookieSid,
1997
2113
  userId: me?.userId ?? null,
1998
2114
  orgId: me?.currentOrg?.id ?? null,
1999
2115
  projectId: getState().projectId ?? null,
2000
- name: null,
2001
- capital: null,
2116
+ name: me?.surfaceName ?? null,
2117
+ capital: me?.surfaceName ? (me.surfaceName[0] || '').toUpperCase() : null,
2118
+ nameRequired,
2119
+ suggestedName: getSuggestedSessionName(),
2002
2120
  color: null,
2003
2121
  surfaced: false,
2004
2122
  alive: false,
@@ -2009,18 +2127,30 @@ async function sessionSurfaceBlock() {
2009
2127
  };
2010
2128
  }
2011
2129
 
2012
- // Identity: report THIS agent session's own surface identity. Read-only — no state changed.
2013
- 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. Read-only.', {}, async () => {
2130
+ // Identity + server health: report THIS agent session's own surface identity, server
2131
+ // reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
2132
+ // cached (5min), so repeat `whoami` calls are free; the server-side update gate still
2133
+ // blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
2134
+ 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 () => {
2014
2135
  try {
2136
+ // Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
2137
+ // fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
2138
+ await ensureSession();
2015
2139
  const block = await sessionSurfaceBlock();
2140
+ const mcpUpdate = await getCachedMcpUpdateMetadata();
2016
2141
  // Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
2017
2142
  // enough; without an explicit instruction agents rarely say which session they are, so users
2018
2143
  // can't match them to their tab on the Drafted surface.
2019
- const instruction = block.name
2020
- ? `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.`
2021
- : undefined;
2144
+ const instruction = block.nameRequired
2145
+ ? `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>".`
2146
+ : block.name
2147
+ ? `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.`
2148
+ : undefined;
2022
2149
  return ok({
2023
2150
  server: getServerUrl(),
2151
+ ok: mcpUpdate.status !== 'unknown',
2152
+ mcpVersion: PACKAGE_VERSION,
2153
+ mcpUpdate,
2024
2154
  editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
2025
2155
  agentLabel: getAgentLabel(),
2026
2156
  ...block,
@@ -2029,342 +2159,29 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
2029
2159
  } catch (error) { return err(error); }
2030
2160
  });
2031
2161
 
2032
- // Health: server reachability + installed-MCP staleness, meant to be the second call of a
2033
- // session (right after whoami, before real work) so a required update surfaces early instead
2034
- // of depending on an agent remembering to call get_org. Cached per-process `whoami` stays
2035
- // network-free between health checks, and repeat `health` calls in one session are free too.
2036
- tool('health', {}, async () => {
2162
+ // Session naming: set/rename THIS agent session's name the name-before-work gate stays
2163
+ // closed until this succeeds. The name persists server-side (sessions.surface_name), so
2164
+ // reconnects and server restarts keep it and the gate never re-fires for a named session.
2165
+ 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.', {
2166
+ action: z.enum(['name']).describe('Operation currently only `name` (set/rename this session).'),
2167
+ 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.'),
2168
+ }, async ({ action, name }) => {
2169
+ if (action !== 'name') return err(new Error(`unknown session action: ${action}`));
2170
+ 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")'));
2037
2171
  try {
2038
- const mcpUpdate = await getCachedMcpUpdateMetadata();
2039
- return ok({
2040
- server: getServerUrl(),
2041
- ok: mcpUpdate.status !== 'unknown',
2042
- mcpVersion: PACKAGE_VERSION,
2043
- mcpUpdate,
2044
- });
2172
+ const result = await api('POST', '/api/sessions/name', { name: String(name) });
2173
+ if (agentSurface) {
2174
+ agentSurface.name = result?.name || String(name).trim();
2175
+ agentSurface.nameRequired = false;
2176
+ agentSurface.capital = ((result?.name || String(name).trim())[0] || '').toUpperCase();
2177
+ }
2178
+ return ok({ sessionId: getState().sessionId, name: result?.name || String(name).trim(), named: true });
2045
2179
  } catch (error) { return err(error); }
2046
2180
  });
2047
2181
 
2048
2182
  // ── Project management tools (direct HTTP) ────────────────────────
2049
2183
 
2050
- tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org), export (the project as an OKF v0.1 bundle — <layer>/<lane>/<file>.md concepts, index.md/log.md synthesized), import (ingest an OKF bundle: concepts become markdown document frames, links between them become connectors; dryRun supported). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
2051
- action: z.enum(['list', 'open', 'create', 'update', 'move', 'export', 'import']).describe('Operation to perform.'),
2052
- projectId: z.string().optional().describe('[open|update|move|export|import] project ID. Get IDs from action=list. For export/import: defaults to the bound project.'),
2053
- name: z.string().optional().describe('[create|update] project name'),
2054
- description: z.string().nullable().optional().describe('[create|update] project description'),
2055
- templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
2056
- org: z.string().optional().describe('[create] org slug or id the project is born in. Defaults to this session\'s org (the open project\'s org, else the default org).'),
2057
- folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder). Pass "Archive" to archive: the project drops out of search and the active list but is never deleted and can be restored by a human.'),
2058
- layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
2059
- targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
2060
- format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
2061
- limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
2062
- offset: z.number().optional().describe('[export] pagination offset for format="files"'),
2063
- compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
2064
- files: z.array(z.object({
2065
- path: z.string().describe('Bundle-relative file path, e.g. "research/default/notes.md"'),
2066
- content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
2067
- })).optional().describe('[import] OKF bundle files inline. index.md/log.md are skipped (synthesized). Caps: 500 files, 512KB/file, 5MB total.'),
2068
- dryRun: z.boolean().optional().describe('[import] preview the {creates, updates, skips, warnings} report without writing'),
2069
- ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-project-<slug>). import: recursively read .md files from here (alternative to files[]).') }),
2070
- }, async (args) => {
2071
- try {
2072
- const { action } = args;
2073
- switch (action) {
2074
- case 'list': {
2075
- const data = await api('GET', '/api/projects');
2076
- data.agentProject = getState().projectId || null;
2077
- try {
2078
- const favData = await api('GET', '/api/skills/favorites');
2079
- const favs = favData.skills || [];
2080
- if (favs.length > 0) {
2081
- data.favoritedSkills = favs.map(s => ({
2082
- id: s.id,
2083
- name: s.name,
2084
- slug: s.slug,
2085
- description: s.description,
2086
- tags: s.tags,
2087
- }));
2088
- }
2089
- } catch { /* skills not available */ }
2090
-
2091
- const structuredContent = {
2092
- projects: (data.projects || []).map(p => ({
2093
- id: p.id,
2094
- name: p.name,
2095
- slug: p.slug,
2096
- description: p.description,
2097
- orgId: p.orgId,
2098
- })),
2099
- activeProject: data.agentProject,
2100
- };
2101
- return ok(data, { structuredContent });
2102
- }
2103
- case 'open': {
2104
- const ref = args.projectId;
2105
- if (!ref) throw new Error('projectId required for action=open (UUID, slug, or name)');
2106
- // Accept a slug or name, not just a UUID — the slug is what an agent actually has
2107
- // in context after a reconnect.
2108
- const resolved = await resolveProjectRef(ref).catch(() => null);
2109
- const projectId = resolved?.id || ref;
2110
- const result = await api('POST', '/api/project/switch', { projectId });
2111
- joinAgentWsRoom(projectId);
2112
- const base = getServerUrl();
2113
- const projectMeta = resolved || { id: projectId, slug: null, name: null, orgId: null, orgSlug: null };
2114
- const projectSlug = projectMeta.slug || projectId;
2115
- const orgSlug = projectMeta.orgSlug || null;
2116
- setMcpActiveProject(projectId, projectMeta);
2117
- // Semantic /o/<org-slug>/<project-slug> when the org slug is known; otherwise
2118
- // the /project/<slug> resolver redirects to it.
2119
- const url = orgSlug ? `${base}/o/${encodeURIComponent(orgSlug)}/${encodeURIComponent(projectSlug)}` : `${base}/project/${projectSlug}`;
2120
- // Surfacing to the user is the focus mechanism's job now (agent-active ping ->
2121
- // desktop window / notification+glow for browser tabs) — this used to also force
2122
- // `exec('open <url>')` on the MCP host machine, an unsolicited GUI action that both
2123
- // duplicated the focus mechanism and did nothing useful for remote/hosted MCP mode
2124
- // (no GUI to open on Drafted's own server). `url` is still returned below for the
2125
- // agent/human to open manually.
2126
- let navigated = 0;
2127
- try {
2128
- const nav = await api('POST', '/api/project/navigate', { projectId });
2129
- navigated = nav.navigated || 0;
2130
- } catch { /* server may not support navigate yet */ }
2131
- // G4/G5 auto-inject (locked design): the project's attached skills + anchors
2132
- // are pushed into the open response within the per-project context budget,
2133
- // replacing the reject-style gate. Prefer the server-computed `priming`
2134
- // (authoritative + fresh on deploy); fall back to MCP-side assembly for
2135
- // older servers that don't return it.
2136
- let responseExtras;
2137
- const priming = result && result.priming ? result.priming : null;
2138
- if (priming) {
2139
- const primedSkills = priming.skills || [];
2140
- const primedAnchors = priming.anchors || [];
2141
- for (const s of primedSkills) getSessionState().loadedSkillIds.add(s.id);
2142
- responseExtras = { url, opened: true, navigated, skills: primedSkills, anchors: primedAnchors };
2143
- if (priming.budgetNotice) responseExtras.budgetNotice = priming.budgetNotice;
2144
- } else {
2145
- let projectSkillsList = [];
2146
- try {
2147
- const skillData = await api('GET', `/api/projects/${projectId}/skills`);
2148
- projectSkillsList = skillData.skills || [];
2149
- } catch { /* skills not available yet */ }
2150
- for (const s of projectSkillsList) {
2151
- try {
2152
- const full = await api('GET', `/api/skills/${s.id}`);
2153
- s.content = full.content;
2154
- s.files = full.files || [];
2155
- } catch { /* leaves this skill without inlined content */ }
2156
- }
2157
- let anchors = [];
2158
- try {
2159
- const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
2160
- anchors = (Array.isArray(anchored) ? anchored : []).map(a => ({
2161
- id: a.id,
2162
- path: a.path || `/${a.layer || ''}/${a.lane || ''}/${a.label || ''}`,
2163
- layer: a.layer,
2164
- content: a.content || '',
2165
- }));
2166
- } catch { /* anchors unavailable */ }
2167
- const sel = selectWithinBudget([...projectSkillsList, ...anchors], PROJECT_CONTEXT_BUDGET_CHARS);
2168
- for (const it of sel.included) {
2169
- if (projectSkillsList.includes(it)) getSessionState().loadedSkillIds.add(it.id);
2170
- }
2171
- for (const it of sel.deferred) { it.content = undefined; if ('files' in it) it.files = undefined; }
2172
- responseExtras = { url, opened: true, navigated, skills: projectSkillsList, anchors };
2173
- if (sel.deferred.length > 0) {
2174
- responseExtras.budgetNotice =
2175
- `${sel.deferred.length} attached skill(s)/anchor(s) exceeded the per-project context budget ` +
2176
- `(${PROJECT_CONTEXT_BUDGET_CHARS} chars) and were not inlined — load explicitly if needed: ` +
2177
- sel.deferred.map(it => it.slug || it.path || it.id).join(', ');
2178
- }
2179
- }
2180
- // Don't echo the raw priming blob (surfaced via skills/anchors/budgetNotice).
2181
- if (result && typeof result === 'object') delete result.priming;
2182
- return ok({ ...result, ...responseExtras });
2183
- }
2184
- case 'create': {
2185
- const g3 = g3Block(getSessionState().gates);
2186
- if (g3) return err(new Error(g3));
2187
- const { name, description, templateSlug, org } = args;
2188
- if (!name) throw new Error('name required for action=create');
2189
- // Don't silently create the project in whatever org the session inherited.
2190
- await requireBoundOrgForProjectlessMutation(org);
2191
- const body = { name };
2192
- if (description) body.description = description;
2193
- if (templateSlug) body.templateSlug = templateSlug;
2194
- // `org` targets a specific org without switching the active org.
2195
- const createExtra = org ? { 'X-Drafted-Org': org } : {};
2196
- const created = await api('POST', '/api/projects', body, createExtra);
2197
- return ok({ ...withProjectBreadcrumb(created), ...(await orgEcho(created, org)) });
2198
- }
2199
- case 'update': {
2200
- const { projectId, name, folder, description, layers } = args;
2201
- if (!projectId) throw new Error('projectId required for action=update');
2202
- const body = {};
2203
- if (name !== undefined) body.name = name;
2204
- if (folder !== undefined) {
2205
- // "Archive" is the agent-facing name for the reserved __archived folder:
2206
- // archived projects drop out of search and the active list but are never
2207
- // deleted, so an agent can safely archive a project it created in error
2208
- // and a human can always restore it. Pass any other folder name to file,
2209
- // or null to remove from a folder.
2210
- body.folder = folder === 'Archive' ? '__archived' : folder;
2211
- }
2212
- if (description !== undefined) body.description = description;
2213
- if (layers) body.layers = layers;
2214
- if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required for action=update');
2215
- return ok(await api('PATCH', `/api/project/${projectId}`, body));
2216
- }
2217
- case 'move': {
2218
- const { projectId, targetOrgId } = args;
2219
- if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
2220
- return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
2221
- }
2222
-
2223
- // ── export ──────────────────────────────────────────────────
2224
- // The project as an OKF v0.1 bundle. Mirrors wiki export: format="files"
2225
- // pages the bundle inline; otherwise stdio writes a local dir, remote
2226
- // returns the authenticated tar.gz download URL.
2227
- case 'export': {
2228
- const projectId = args.projectId || getState().projectId;
2229
- if (!projectId) throw new Error('projectId required for action=export (or open a project first)');
2230
- if (args.format === 'files') {
2231
- const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
2232
- if (args.offset) qp.set('offset', String(args.offset));
2233
- if (args.compact) qp.set('compact', 'true');
2234
- return ok(await api('GET', `/api/projects/${projectId}/export?${qp.toString()}`));
2235
- }
2236
- if (isRemote) {
2237
- return ok({
2238
- downloadUrl: `${getServerUrl()}/api/projects/${projectId}/export.tar.gz`,
2239
- note: 'Open the URL in a signed-in browser to download the OKF v0.1 project bundle, or call export with format="files" to page the bundle contents inline.',
2240
- });
2241
- }
2242
- // stdio: write every bundle file under a local directory.
2243
- const meta = getCurrentProjectContext();
2244
- const projSlug = String((meta && meta.id === projectId && (meta.slug || meta.name)) || projectId)
2245
- .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
2246
- const exportDir = resolve(args.dir || `./okf-project-${projSlug}`);
2247
- let expOffset = 0;
2248
- let written = 0;
2249
- for (;;) {
2250
- const batch = await api('GET', `/api/projects/${projectId}/export?limit=200&offset=${expOffset}`);
2251
- const batchFiles = batch.files || [];
2252
- for (const f of batchFiles) {
2253
- const dest = resolve(exportDir, f.path);
2254
- if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
2255
- mkdirSync(dirname(dest), { recursive: true });
2256
- writeFileSync(dest, f.content, 'utf8');
2257
- written++;
2258
- }
2259
- expOffset += batchFiles.length;
2260
- if (!batch.truncated || batchFiles.length === 0) break;
2261
- }
2262
- return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 project bundle written (<layer>/<lane>/<file>.md concepts; index.md and log.md are synthesized).' });
2263
- }
2264
-
2265
- // ── import ──────────────────────────────────────────────────
2266
- // Ingest an OKF bundle into the project: inline files[] or (stdio) a
2267
- // local dir walked for .md files. Concepts become markdown document
2268
- // frames; markdown links between them become connectors.
2269
- case 'import': {
2270
- const projectId = args.projectId || getState().projectId;
2271
- if (!projectId) throw new Error('projectId required for action=import (or open a project first)');
2272
- let importFiles = args.files;
2273
- if (!importFiles && args.dir) {
2274
- const root = resolve(args.dir);
2275
- if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
2276
- importFiles = [];
2277
- const walk = (d) => {
2278
- for (const ent of readdirSync(d, { withFileTypes: true })) {
2279
- if (ent.name.startsWith('.')) continue;
2280
- const p = join(d, ent.name);
2281
- if (ent.isDirectory()) walk(p);
2282
- else if (/\.md$/i.test(ent.name)) {
2283
- if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
2284
- importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
2285
- }
2286
- }
2287
- };
2288
- walk(root);
2289
- }
2290
- if (!Array.isArray(importFiles) || importFiles.length === 0) {
2291
- throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
2292
- }
2293
- return ok(await api('POST', `/api/projects/${projectId}/import`, { files: importFiles, dryRun: !!args.dryRun }));
2294
- }
2295
-
2296
- default:
2297
- throw new Error(`Unknown project action: ${action}`);
2298
- }
2299
- } catch (error) { return err(error); }
2300
- });
2301
2184
 
2302
- tool('template', 'Manage project templates in an org. Dispatch by `action`: list/create/update/delete/fork. A template bundles layer definitions and an optional list of skill slugs that auto-attach to projects created from it. Scopes to the open project\'s org by default; pass org=... to target another org (per-request only — nothing is switched).', {
2303
- action: z.enum(['list', 'create', 'update', 'delete', 'fork']).describe('Operation to perform.'),
2304
- templateId: z.string().optional().describe('[update|delete|fork] template ID'),
2305
- name: z.string().optional().describe('[create|update|fork] template name (required for create; optional rename for fork)'),
2306
- description: z.string().optional().describe('[create|update] template description'),
2307
- layers: z.array(z.object({}).passthrough()).optional().describe('[create|update] array of layer definitions'),
2308
- skillSlugs: z.array(z.string()).optional().describe('[create|update] skill slugs to auto-attach to projects created from this template. Slugs are resolved against the org\'s skills (org-local first, built-in fallback) at project-create time; missing slugs are silently skipped. Pass an empty array on update to clear.'),
2309
- visibility: z.string().optional().describe('[create|update] "org" or "public"'),
2310
- compact: z.boolean().optional().describe('[list] default true — return {id,slug,name,description,layerCount,skillSlugs} only. Pass false for full layer definitions.'),
2311
- limit: z.number().optional().describe('[list] max templates to return (default 25, max 100).'),
2312
- offset: z.number().optional().describe('[list] pagination offset.'),
2313
- org: z.string().optional().describe('Org (id or name) to scope this call to — defaults to the open project\'s org. Per-request only.'),
2314
- }, async (args) => {
2315
- try {
2316
- const { action } = args;
2317
- const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
2318
- switch (action) {
2319
- case 'list': {
2320
- const tpls = await api('GET', '/api/templates', undefined, orgHeader);
2321
- markSearched(getSessionState().gates, 'template');
2322
- return ok(shapeTemplateCatalog(tpls, { limit: args.limit, offset: args.offset, compact: args.compact !== false }));
2323
- }
2324
- case 'create': {
2325
- const { name, description, layers, skillSlugs, visibility } = args;
2326
- if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
2327
- // Don't silently create the template in whatever org the session inherited.
2328
- await requireBoundOrgForProjectlessMutation(args.org);
2329
- const body = { name, description, layers };
2330
- if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
2331
- if (visibility) body.visibility = visibility;
2332
- const createdTpl = await api('POST', '/api/templates', body, orgHeader);
2333
- return ok({ ...createdTpl, ...(await orgEcho(createdTpl, args.org)) });
2334
- }
2335
- case 'update': {
2336
- const { templateId, name, description, layers, skillSlugs, visibility } = args;
2337
- if (!templateId) throw new Error('templateId required for action=update');
2338
- const body = {};
2339
- if (name) body.name = name;
2340
- if (description) body.description = description;
2341
- if (layers) body.layers = layers;
2342
- if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
2343
- if (visibility) body.visibility = visibility;
2344
- if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
2345
- const updatedTpl = await api('PUT', `/api/templates/${templateId}`, body, orgHeader);
2346
- return ok({ ...updatedTpl, ...(await orgEcho(updatedTpl, args.org)) });
2347
- }
2348
- case 'delete': {
2349
- const { templateId } = args;
2350
- if (!templateId) throw new Error('templateId required for action=delete');
2351
- return ok(await api('DELETE', `/api/templates/${templateId}`, undefined, orgHeader));
2352
- }
2353
- case 'fork': {
2354
- const { templateId, name } = args;
2355
- if (!templateId) throw new Error('templateId required for action=fork');
2356
- // Forking creates a new template — same org rule as any create.
2357
- await requireBoundOrgForProjectlessMutation(args.org);
2358
- const body = {};
2359
- if (name) body.name = name;
2360
- const forkedTpl = await api('POST', `/api/templates/${templateId}/fork`, body, orgHeader);
2361
- return ok({ ...forkedTpl, ...(await orgEcho(forkedTpl, args.org)) });
2362
- }
2363
- default:
2364
- throw new Error(`Unknown template action: ${action}`);
2365
- }
2366
- } catch (error) { return err(error); }
2367
- });
2368
2185
 
2369
2186
  tool('trigger', {
2370
2187
  action: z.enum(['create', 'list', 'update', 'rotate', 'test', 'deliveries', 'delete', 'pending', 'claim', 'complete']).describe('Operation to perform.'),
@@ -2379,28 +2196,35 @@ tool('trigger', {
2379
2196
  deliveryId: z.string().optional().describe('[complete] delivery ID returned by claim'),
2380
2197
  ok: z.boolean().optional().describe('[complete] whether the claimed work succeeded (default true)'),
2381
2198
  error: z.string().optional().describe('[complete] error note when ok=false'),
2382
- projectId: z.string().optional().describe('[create|list|pending] target project defaults to the active (opened) project.'),
2199
+ projectId: z.string().optional().describe('[create|list|pending] the project this trigger belongs to REQUIRED, as a path (/projects/<name> or /projects/<folder>/<name>), slug, or UUID. Triggers are project-scoped: they run agent conversations in that project. No session fallback — name the project.'),
2383
2200
  limit: z.number().optional().describe('[deliveries] max rows (default 25, max 100)'),
2384
2201
  }, async (args) => {
2385
2202
  try {
2386
2203
  const { action } = args;
2204
+ // Triggers are project-scoped. Resolve the project from the fs path (or
2205
+ // slug/UUID), scope the API calls via withProjectOverride so api() appends
2206
+ // THIS project (not whatever session is bound), and echo projectPath back.
2207
+ const project = action === 'create' || action === 'list' || action === 'pending' ? await resolveProjectArg(args.projectId) : null;
2208
+ const projectPath = project ? `/projects/${project.slug}` : null;
2209
+ const scoped = (fn) => project ? withProjectOverride(project, fn) : fn();
2387
2210
  switch (action) {
2388
2211
  case 'create': {
2389
2212
  if (!args.name || !args.promptTemplate) throw new Error('name and promptTemplate required for action=create');
2390
- const body = { name: args.name, promptTemplate: args.promptTemplate };
2391
- if (args.projectId) body.projectId = args.projectId;
2213
+ const body = { name: args.name, promptTemplate: args.promptTemplate, projectId: project.id };
2392
2214
  if (args.executor) body.executor = args.executor;
2393
2215
  if (args.signingSecret) body.signingSecret = args.signingSecret;
2394
2216
  if (args.dailyLimit !== undefined) body.dailyLimit = args.dailyLimit;
2395
- const created = await api('POST', '/api/triggers', body);
2217
+ const created = await scoped(() => api('POST', '/api/triggers', body));
2396
2218
  return ok({
2397
2219
  ...created,
2220
+ projectPath,
2398
2221
  note: 'Relay `url` (and the HMAC secret if set) to the user NOW — the token is shown once and cannot be retrieved later, only rotated.',
2399
2222
  });
2400
2223
  }
2401
2224
  case 'list': {
2402
- const qs = args.projectId ? `?projectId=${encodeURIComponent(args.projectId)}` : '';
2403
- return ok(await api('GET', `/api/triggers${qs}`));
2225
+ const result = await scoped(() => api('GET', '/api/triggers'));
2226
+ const triggers = (result.triggers || []).map(t => ({ ...t, projectPath }));
2227
+ return ok({ ...result, triggers, projectPath });
2404
2228
  }
2405
2229
  case 'update': {
2406
2230
  if (!args.triggerId) throw new Error('triggerId required for action=update');
@@ -2434,8 +2258,7 @@ tool('trigger', {
2434
2258
  return ok(await api('DELETE', `/api/triggers/${args.triggerId}`));
2435
2259
  }
2436
2260
  case 'pending': {
2437
- const qs = args.projectId ? `?projectId=${encodeURIComponent(args.projectId)}` : '';
2438
- return ok(await api('GET', `/api/triggers/pending${qs}`));
2261
+ return ok(await scoped(() => api('GET', '/api/triggers/pending').then(r => ({ ...r, projectPath }))));
2439
2262
  }
2440
2263
  case 'claim': {
2441
2264
  if (!args.triggerId) throw new Error('triggerId required for action=claim');
@@ -2457,67 +2280,55 @@ tool('trigger', {
2457
2280
  });
2458
2281
 
2459
2282
  tool('focus', {
2460
- target: z.string().describe('Frame URL (any URL containing /f/{uuid}), frame ID (UUID), or file path (/{layer}/{lane}/{filename}) to pan the canvas viewport to. When a user shares a Drafted frame link, pass it directly here.'),
2283
+ target: z.string().describe('What to pan the canvas to: a frame path (/projects/<project>/<layer>/<lane>/<file>), a frame URL (any URL containing /f/{uuid} or /o/<org>/projects/...), or a frame ID (UUID). When a user shares a Drafted link, pass it directly here.'),
2461
2284
  }, async ({ target }) => {
2462
2285
  try {
2463
- // Resolve target to a frame ID
2464
- const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
2465
- const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
2466
- let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
2467
-
2468
- if (!frameId) {
2469
- // Path — resolve via read to get the frame ID
2470
- const parts = target.replace(/^\/+/, '').split('/');
2471
- if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
2472
- const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
2473
- if (!frame.id) throw new Error('Frame not found');
2474
- frameId = frame.id;
2475
- }
2476
-
2286
+ const { frameId } = await resolveFsFramePath(target);
2477
2287
  const result = await api('POST', `/api/focus/${frameId}`);
2478
2288
  return ok(result);
2479
2289
  } catch (error) { return err(error); }
2480
2290
  });
2481
2291
 
2482
- tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a single frame (default 1440×900, fullPage). `scope=canvas` captures a region of the project surface (default 1600×1200, typically the "plans" layer where frames live).', {
2292
+ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a single frame; `scope=canvas` captures a region of the project surface (default 1600×1200, typically the "plans" layer). Targets are pseudo-filesystem paths: `scope=frame` takes /projects/<project>/<layer>/<lane>/<file> (or a frame URL / UUID); `scope=canvas` takes /projects/<project> with an optional `layer`.', {
2483
2293
  scope: z.enum(['frame', 'canvas']).describe('What to capture.'),
2484
- target: z.string().optional().describe('[scope=frame] frame URL, UUID, or /{layer}/{lane}/{filename} path.'),
2485
- slug: z.string().optional().describe('[scope=canvas] project slug. Defaults to the currently active project.'),
2486
- layer: z.string().optional().describe('[scope=canvas] layer key to capture (default: plans).'),
2294
+ path: z.string().optional().describe('[frame] /projects/<project>/<layer>/<lane>/<file>, a frame URL (/f/{uuid} or /o/<org>/projects/...), or a frame UUID. [canvas] /projects/<project> (project root) — captures that project\'s surface.'),
2295
+ layer: z.string().optional().describe('[canvas] layer key to capture (default: plans).'),
2487
2296
  width: z.number().optional().describe('Viewport width in pixels (frame default 1440, canvas default 1600).'),
2488
2297
  height: z.number().optional().describe('Viewport height in pixels (frame default 900, canvas default 1200).'),
2489
- fullPage: z.boolean().optional().describe('[scope=frame] capture full page or just viewport (default true).'),
2298
+ fullPage: z.boolean().optional().describe('[frame] capture full page or just viewport (default true).'),
2490
2299
  // outputPath is local-only — omitted on remote transports (web MCP) where the host has no caller filesystem.
2491
2300
  ...(isRemote ? {} : { outputPath: z.string().optional().describe('[stdio only] Absolute local path to write the rendered PNG to. When set, the PNG is saved to disk and the tool returns the path + byte count instead of returning the image inline — use this to get rendered pixels OUT of Drafted (attach to a message, upload, post-process). Parent directories are created if missing.') }),
2301
+ // Legacy aliases (pre-fs-path): kept for installed-client compat.
2302
+ target: z.string().optional().describe('[legacy frame] frame URL, UUID, or /{layer}/{lane}/{filename} path.'),
2303
+ slug: z.string().optional().describe('[legacy canvas] project slug. Defaults to the currently active project.'),
2492
2304
  }, async (args) => {
2493
2305
  try {
2494
2306
  const { scope } = args;
2495
2307
  let buffer;
2496
2308
  if (scope === 'frame') {
2497
- const { target, width = 1440, height = 900, fullPage = true } = args;
2498
- if (!target) throw new Error('target required for scope=frame');
2499
- const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
2500
- const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
2501
- let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
2502
- if (!frameId) {
2503
- const parts = target.replace(/^\/+/, '').split('/');
2504
- if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
2505
- const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
2506
- if (!frame.id) throw new Error('Frame not found');
2507
- frameId = frame.id;
2508
- }
2309
+ const target = args.path || args.target;
2310
+ const { width = 1440, height = 900, fullPage = true } = args;
2311
+ if (!target) throw new Error('path required for scope=frame (e.g. /projects/<project>/<layer>/<lane>/<file>)');
2312
+ const { frameId } = await resolveFsFramePath(target);
2509
2313
  const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
2510
2314
  await ensureSession();
2511
2315
  const res = await fetch(url, { headers: getAuthHeaders() });
2512
2316
  if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
2513
2317
  buffer = Buffer.from(await res.arrayBuffer());
2514
2318
  } else if (scope === 'canvas') {
2515
- const { slug, layer = 'plans', width = 1600, height = 1200 } = args;
2516
- let targetSlug = slug;
2319
+ const { path: canvasPath, layer = 'plans', width = 1600, height = 1200 } = args;
2320
+ let targetSlug = args.slug;
2321
+ if (!targetSlug && canvasPath) {
2322
+ // /o/<org>/projects/<project> or /projects/<project> or /projects/<folder>/<project> → project slug
2323
+ const scoped = splitOrgScope(canvasPath);
2324
+ if (scoped.error) throw new Error(scoped.error);
2325
+ const parts = scoped.path.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
2326
+ targetSlug = parts[parts.length - 1];
2327
+ }
2517
2328
  if (!targetSlug) {
2518
2329
  const list = await api('GET', '/api/projects');
2519
2330
  const active = (list.projects || []).find(p => p.id === list.activeProject);
2520
- if (!active) throw new Error('No active project — pass slug explicitly.');
2331
+ if (!active) throw new Error('No active project — pass path=/projects/<project> explicitly.');
2521
2332
  targetSlug = active.slug;
2522
2333
  }
2523
2334
  const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
@@ -2542,2188 +2353,65 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
2542
2353
  } catch (error) { return err(error); }
2543
2354
  });
2544
2355
 
2545
- tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remove/reorder. Layers are the horizontal bands of a Drafted canvas (e.g. wireframes, designs, brand-assets). All actions take projectId; add/update/remove also take the layer `key`.', {
2546
- action: z.enum(['add', 'update', 'remove', 'reorder']).describe('Operation to perform.'),
2547
- projectId: z.string().describe('Project ID (all actions require this)'),
2548
- key: z.string().optional().describe('[add|update|remove] unique layer key (e.g. "research", "prototypes")'),
2549
- label: z.string().optional().describe('[add|update] display label'),
2550
- type: z.string().optional().describe('[add|update] layer type (e.g. "html", "image", "text")'),
2551
- width: z.number().optional().describe('[add|update] default frame width in pixels'),
2552
- height: z.number().optional().describe('[add|update] default frame height in pixels'),
2553
- description: z.string().optional().describe('[add|update] layer description'),
2554
- prompt: z.string().optional().describe('[add|update] prompt hint for AI agents working in this layer'),
2555
- force: z.boolean().optional().describe('[remove] force removal even if the layer contains frames'),
2556
- keys: z.array(z.string()).optional().describe('[reorder] ordered array of ALL existing layer keys — no additions, removals, or duplicates'),
2557
- }, async (args) => {
2558
- try {
2559
- const { action, projectId } = args;
2560
- if (!projectId) throw new Error('projectId is required');
2561
- switch (action) {
2562
- case 'add': {
2563
- const { key, label, type, width, height, description, prompt } = args;
2564
- if (!key || !label || !type || width == null || height == null) {
2565
- throw new Error('key, label, type, width, height are required for action=add');
2566
- }
2567
- const project = await api('GET', `/api/project/${projectId}`);
2568
- const layers = project.layers || [];
2569
- if (layers.some(l => l.key === key)) {
2570
- throw new Error(`Layer with key "${key}" already exists in this project`);
2571
- }
2572
- const newLayer = { key, label, type, width, height };
2573
- if (description !== undefined) newLayer.description = description;
2574
- if (prompt !== undefined) newLayer.prompt = prompt;
2575
- layers.push(newLayer);
2576
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
2577
- }
2578
- case 'update': {
2579
- const { key, label, type, width, height, description, prompt } = args;
2580
- if (!key) throw new Error('key is required for action=update');
2581
- const project = await api('GET', `/api/project/${projectId}`);
2582
- const layers = project.layers || project.project?.layers;
2583
- if (!Array.isArray(layers)) throw new Error('Project has no layers array');
2584
- const idx = layers.findIndex(l => l.key === key);
2585
- if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
2586
- const updates = { label, type, width, height, description, prompt };
2587
- const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
2588
- if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required for action=update');
2589
- layers[idx] = { ...layers[idx], ...filtered };
2590
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
2591
- }
2592
- case 'remove': {
2593
- const { key, force = false } = args;
2594
- if (!key) throw new Error('key is required for action=remove');
2595
- const project = await api('GET', `/api/project/${projectId}`);
2596
- const layers = project.layers || [];
2597
- if (!layers.some(l => l.key === key)) {
2598
- throw new Error(`Layer with key "${key}" does not exist in this project`);
2599
- }
2600
- if (!force) {
2601
- const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
2602
- const entries = listing.entries || [];
2603
- const frames = entries.filter(e => e.type === 'frame');
2604
- const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md') && !f.path.endsWith('/instructions/AGENTS.md') && !f.path.endsWith('/AGENTS.md'));
2605
- const frameCount = realFrames.length;
2606
- if (frameCount > 0) {
2607
- throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
2608
- }
2609
- }
2610
- const filtered = layers.filter(l => l.key !== key);
2611
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
2612
- }
2613
- case 'reorder': {
2614
- const { keys } = args;
2615
- if (!Array.isArray(keys)) throw new Error('keys (array) is required for action=reorder');
2616
- const project = await api('GET', `/api/project/${projectId}`);
2617
- const currentLayers = project.layers || [];
2618
- const currentKeys = currentLayers.map(l => l.key);
2619
- const uniqueKeys = new Set(keys);
2620
- if (uniqueKeys.size !== keys.length) {
2621
- const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
2622
- throw new Error(`Duplicate keys: ${[...new Set(dupes)].join(', ')}`);
2623
- }
2624
- const missing = currentKeys.filter(k => !uniqueKeys.has(k));
2625
- if (missing.length > 0) {
2626
- throw new Error(`Missing keys: ${missing.join(', ')}. You must include all existing layer keys.`);
2627
- }
2628
- const currentSet = new Set(currentKeys);
2629
- const extra = keys.filter(k => !currentSet.has(k));
2630
- if (extra.length > 0) {
2631
- throw new Error(`Unknown keys: ${extra.join(', ')}. Only existing layer keys are allowed.`);
2632
- }
2633
- const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
2634
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
2635
- }
2636
- default:
2637
- throw new Error(`Unknown layer action: ${action}`);
2638
- }
2639
- } catch (error) { return err(error); }
2640
- });
2641
2356
 
2642
2357
 
2643
- async function getGoogleDriveAvailability() {
2644
- try {
2645
- const status = await api('GET', '/api/google/status');
2646
- return {
2647
- connected: !!status?.connected,
2648
- syncEnabled: !!status?.syncEnabled,
2649
- driveRootFolderId: status?.driveRootFolderId || null,
2650
- driveRootFolderName: status?.driveRootFolderName || null,
2651
- workspaceFramesAvailable: !!status?.connected,
2652
- preference: status?.connected
2653
- ? 'Strongly prefer Google Workspace frames for docs, sheets, and slides in this org.'
2654
- : 'Google Drive is not connected; use normal Drafted frames.',
2655
- };
2656
- } catch {
2657
- return {
2658
- connected: false,
2659
- syncEnabled: false,
2660
- driveRootFolderId: null,
2661
- driveRootFolderName: null,
2662
- workspaceFramesAvailable: false,
2663
- preference: 'Google Drive status unavailable; use normal Drafted frames unless a Google Workspace frame succeeds.',
2664
- };
2665
- }
2666
- }
2667
2358
 
2668
- function normalizeMcpUpdatePolicy(policy) {
2669
- const severity = policy?.policy?.severity || 'unknown';
2670
- const updateAvailable = !!policy?.policy?.updateAvailable;
2671
- const required = !!policy?.policy?.required;
2672
- return {
2673
- status: required ? 'required' : updateAvailable ? 'stale' : severity === 'unknown' ? 'unknown' : 'current',
2674
- currentVersion: policy?.versions?.client || PACKAGE_VERSION,
2675
- latestVersion: policy?.versions?.latest || null,
2676
- recommendedVersion: policy?.versions?.recommended || null,
2677
- minimumRequiredVersion: policy?.versions?.minimumRequired || null,
2678
- severity,
2679
- updateAvailable,
2680
- stale: updateAvailable,
2681
- required,
2682
- reason: policy?.policy?.reason || null,
2683
- enabled: policy?.policy?.enabled !== false,
2684
- mode: policy?.package?.mcpMode || mcpMode(),
2685
- distribution: policy?.package?.distribution || (mcpMode() === 'stdio' ? 'npm-stdio' : 'hosted-http'),
2686
- update: {
2687
- command: policy?.update?.command || null,
2688
- helper: policy?.update?.helper || null,
2689
- packageManager: policy?.update?.packageManager || 'npm',
2690
- },
2691
- restart: {
2692
- required: !!policy?.restart?.required,
2693
- guidance: policy?.restart?.guidance || null,
2694
- },
2695
- checkedAt: policy?.serverTimestamp || null,
2359
+
2360
+
2361
+
2362
+
2363
+ // ── Skill library tool ───────────────────────────────────────────
2364
+
2365
+ // Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
2366
+ // dirs + any .skillignore as a convenience so we don't ship a node_modules tree
2367
+ // over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
2368
+ // CLI's collectSkillTree.
2369
+ function collectSkillTreeForPush(dir) {
2370
+ const root = resolve(dir);
2371
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
2372
+ const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
2373
+ const NUL = String.fromCharCode(0);
2374
+ let ignore = () => false;
2375
+ const ign = join(root, '.skillignore');
2376
+ if (existsSync(ign)) {
2377
+ const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
2378
+ ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
2379
+ }
2380
+ const out = [];
2381
+ const walk = (abs, rel) => {
2382
+ for (const name of readdirSync(abs)) {
2383
+ const childAbs = join(abs, name);
2384
+ const childRel = rel ? `${rel}/${name}` : name;
2385
+ const st = statSync(childAbs);
2386
+ if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
2387
+ else if (st.isFile()) {
2388
+ if (ignore(childRel)) continue;
2389
+ const content = readFileSync(childAbs, 'utf8');
2390
+ if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
2391
+ out.push({ path: childRel, content });
2392
+ }
2393
+ }
2696
2394
  };
2395
+ walk(root, '');
2396
+ return out;
2697
2397
  }
2698
2398
 
2699
- function buildInstalledMcpUpdateInstructions(updateMetadata = null) {
2700
- const mode = updateMetadata?.mode || mcpMode();
2701
- const restart = updateMetadata?.restart || {
2702
- required: mode === 'stdio',
2703
- guidance: 'Restart agents after updating the npm-installed Drafted MCP daemon.',
2704
- };
2399
+ // Ensure a pushed source tree's .gitignore excludes the rebuildable bundle dir, so
2400
+ // a skill's machine-specific build output (built into .skillinstall/ by its `setup:`
2401
+ // recipe) can never be committed. The server also strips .skillinstall/ from the
2402
+ // bundle (skill-ingest DENY_DIRS); this keeps the author's git clean. Idempotent.
2403
+ function ensureSkillInstallIgnored(dir) {
2404
+ try {
2405
+ const gi = join(dir, '.gitignore');
2406
+ const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
2407
+ if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
2408
+ const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
2409
+ writeFileSync(gi, body + '.skillinstall/\n');
2410
+ return true;
2411
+ } catch { return false; }
2412
+ }
2705
2413
 
2706
- if (mode !== 'stdio') {
2707
- return {
2708
- action: 'update_mcp',
2709
- started: false,
2710
- updateSupported: false,
2711
- mode,
2712
- currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
2713
- latestVersion: updateMetadata?.latestVersion || null,
2714
- updateAvailable: false,
2715
- required: false,
2716
- command: null,
2717
- dryRunCommand: null,
2718
- manualCommand: null,
2719
- restart: {
2720
- required: false,
2721
- guidance: restart.guidance || 'Hosted HTTP MCP updates with the Drafted server deploy.',
2722
- },
2723
- note: 'This session is using hosted HTTP MCP, so there is no npm-installed stdio daemon to update on this machine.',
2724
- };
2725
- }
2726
2414
 
2727
- const server = getServerUrl().replace(/\/$/, '');
2728
- const manualCommand = platform() === 'win32'
2729
- ? `$tmp = Join-Path $env:TEMP "drafted-install.ps1"; Invoke-WebRequest -UseBasicParsing "${server}/install.ps1" -OutFile $tmp; powershell -NoProfile -ExecutionPolicy Bypass -File $tmp`
2730
- : `tmp=$(mktemp); curl -fsSL ${server}/install.sh -o "$tmp" && bash "$tmp"`;
2731
-
2732
- return {
2733
- action: 'update_mcp',
2734
- started: false,
2735
- updateSupported: true,
2736
- mode: 'stdio',
2737
- currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
2738
- latestVersion: updateMetadata?.latestVersion || null,
2739
- recommendedVersion: updateMetadata?.recommendedVersion || null,
2740
- minimumRequiredVersion: updateMetadata?.minimumRequiredVersion || null,
2741
- updateAvailable: !!updateMetadata?.updateAvailable,
2742
- required: !!updateMetadata?.required,
2743
- command: 'drafted update --yes',
2744
- dryRunCommand: 'drafted update --dry-run',
2745
- manualCommand,
2746
- restart: {
2747
- required: true,
2748
- guidance: restart.guidance || 'Restart agents after updating the npm-installed Drafted MCP daemon.',
2749
- },
2750
- note: 'This action is intentionally advisory: it does not replace the currently running MCP process. Run the command, then restart your agent/editor so it starts the updated drafted-mcp.',
2751
- mcpUpdate: updateMetadata || null,
2752
- };
2753
- }
2754
-
2755
- async function getMcpUpdateMetadata() {
2756
- const mode = mcpMode();
2757
- try {
2758
- const query = new URLSearchParams({
2759
- cliVersion: PACKAGE_VERSION,
2760
- mcpMode: mode,
2761
- });
2762
- const policy = await api('GET', `/api/installations/latest?${query.toString()}`);
2763
- return normalizeMcpUpdatePolicy(policy);
2764
- } catch (error) {
2765
- return {
2766
- status: 'unknown',
2767
- currentVersion: PACKAGE_VERSION,
2768
- latestVersion: null,
2769
- recommendedVersion: null,
2770
- minimumRequiredVersion: null,
2771
- severity: 'unknown',
2772
- updateAvailable: false,
2773
- stale: false,
2774
- required: false,
2775
- reason: 'latest_check_failed',
2776
- enabled: false,
2777
- mode,
2778
- distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
2779
- update: { command: null, helper: null, packageManager: 'npm' },
2780
- restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
2781
- checkedAt: null,
2782
- };
2783
- }
2784
- }
2785
-
2786
- // Process-lifetime cache: `health` is meant to be called every session, so avoid a network
2787
- // round-trip on repeat calls. `get_org` shares the cache too (same underlying data).
2788
- let mcpUpdateCache = null; // { data, fetchedAt }
2789
- const MCP_UPDATE_CACHE_MS = 5 * 60_000;
2790
- async function getCachedMcpUpdateMetadata() {
2791
- if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
2792
- const data = await getMcpUpdateMetadata();
2793
- mcpUpdateCache = { data, fetchedAt: Date.now() };
2794
- return data;
2795
- }
2796
-
2797
-
2798
- tool('get_org', {
2799
- action: z.enum(['get', 'update_mcp', 'use']).optional().describe('Default: "get" returns your orgs, this session\'s resolved working org, and Google Drive availability. "use" (with org=) sets THIS session\'s working org — a per-session, per-request DEFAULT for project-less creates/forks. It is NOT the retired sticky cursor: it never overrides a UUID-addressed resource, an explicit org=, or an open project, and it is private to this session (never a shared server row). "update_mcp" returns installed stdio MCP update instructions.'),
2800
- org: z.string().optional().describe('[use] org id or name to set as this session\'s working-org default.'),
2801
- }, async (args = {}) => {
2802
- try {
2803
- const action = args.action || 'get';
2804
-
2805
- if (action === 'update_mcp') {
2806
- const mcpUpdate = await getMcpUpdateMetadata();
2807
- return ok(buildInstalledMcpUpdateInstructions(mcpUpdate));
2808
- }
2809
-
2810
- if (action === 'use') {
2811
- // P3 (DRAFT-36): the explicit per-session working-org. Symmetric with what a remote
2812
- // connection gets from its OAuth org — a stdio agent declares it here. Stored in
2813
- // THIS session's bucket only (never a shared server row), injected as X-Drafted-Org
2814
- // by api(), and only a DEFAULT: a UUID-addressed resource, an explicit org=, or an
2815
- // open project always win. In-memory (not persisted) so it can never clobber another
2816
- // process's boot state — the concurrency invariant holds by construction.
2817
- const want = (args.org || '').trim();
2818
- if (!want) throw new Error('org (id or name) required for action=use');
2819
- const orgs = await getOrgList();
2820
- const byId = orgs.find((o) => o.id === want);
2821
- const byName = orgs.filter((o) => (o.name || '').toLowerCase() === want.toLowerCase());
2822
- const hit = byId || (byName.length === 1 ? byName[0] : null);
2823
- if (!hit) {
2824
- const reason = byName.length > 1 ? `ambiguous org name "${want}"` : `not a member of org "${want}"`;
2825
- throw new Error(`${reason}. Your orgs: ${orgs.map((o) => o.name || o.id).join(', ') || '(none resolvable)'}`);
2826
- }
2827
- getSessionState().boundOrgId = hit.id;
2828
- return ok({
2829
- workingOrg: { id: hit.id, name: hit.name },
2830
- note: `Working org for this session is now "${hit.name}". Project-less creates/forks default here; a UUID-addressed resource, an explicit org=, or an open project still win. Not the retired switch — per-session and per-request, never a shared cursor.`,
2831
- });
2832
- }
2833
-
2834
- // Source of truth = the org this MCP process scopes requests to (what mutations
2835
- // will actually hit). Each MCP process is independent — multiple agents can run
2836
- // in parallel scoped to different orgs. /auth/me reads sessions.org_id directly.
2837
- const me = await api('GET', '/auth/me');
2838
- const sessionOrgId = me?.orgId || null;
2839
-
2840
- const data = await api('GET', '/api/orgs');
2841
- const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
2842
- const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
2843
-
2844
- // This session's WORKING org (P3): where project-less creates/forks land by default.
2845
- // It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
2846
- // or (remote) the connection's org — not the shared session cursor. Announce it so an
2847
- // agent can self-verify without guessing (invariant: "announced, never opaque").
2848
- // Never REPORT a working org the caller isn't a member of. The old fallback
2849
- // fabricated `{ id, name: null }` for an unknown id, which is how a stale/foreign
2850
- // binding read as a real (if nameless) org instead of as the defect it is. If it
2851
- // isn't in the memberships we just fetched, it can't address anything — clear it
2852
- // so the session falls back to asking for an explicit org.
2853
- const sess = getSessionState();
2854
- const workingOrg = sess.boundOrgId ? (orgs.find(o => o.id === sess.boundOrgId) || null) : null;
2855
- if (sess.boundOrgId && !workingOrg) sess.boundOrgId = null;
2856
-
2857
- const googleDrive = await getGoogleDriveAvailability();
2858
- const mcpUpdate = await getCachedMcpUpdateMetadata();
2859
-
2860
- let members = [];
2861
- if (sessionOrgId) {
2862
- try {
2863
- const memberData = await api('GET', `/api/orgs/${sessionOrgId}/members`);
2864
- members = memberData.members || memberData || [];
2865
- } catch { /* no members */ }
2866
- }
2867
- return ok({
2868
- activeOrg,
2869
- workingOrg,
2870
- orgs,
2871
- members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
2872
- googleDrive,
2873
- mcpVersion: PACKAGE_VERSION,
2874
- mcpUpdate,
2875
- session: await sessionSurfaceBlock(),
2876
- note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive. `workingOrg` is where project-less creates/forks land by default — set it explicitly with get_org(action=\"use\", org=...) when you're multi-org and working project-less; a UUID/explicit org=/open project always wins. It is per-session and per-request, never a shared cursor or a switch. `session` is THIS agent's own surface identity — `session.name` is the human-readable tab name the user sees (use it to identify which agent you are); refresh it via whoami. Concurrent MCP sessions can operate on different orgs simultaneously. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
2877
- });
2878
- } catch (error) { return err(error); }
2879
- });
2880
-
2881
- // ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
2882
-
2883
- tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), set_state / get_state (persist or read a deployed windowType:"app" frame\'s hydration state — push e.g. {specText} to drive a generic app frame with data after deploy; the canvas hydrates the app from it on load), Google Sheet actions (`get_sheet`, `read_sheet_values`, `write_sheet_values`, `append_sheet_rows`, `clear_sheet_range`, `update_sheet`), Google Doc actions (`get_doc`, `read_doc_content`, `write_doc_content`, `append_doc_content`, `clear_doc_content`, `update_doc`), Google Slide actions (`get_slide`, `read_slide_content`, `write_slide_content`, `append_slides`, `clear_slides`, `update_slide`), write_excalidraw (native editable Excalidraw diagram — full-scene replace), edit_excalidraw (element-level upsert/remove by id, for growing or surgically editing a large scene without resending it), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Google Workspace native content:** Create or attach Google Docs/Sheets/Slides with `frame(action="write", googleType=...)`. After creating, immediately populate the native file using the matching write action in the same tool — do NOT leave it empty and do NOT tell the user you cannot write to it. For Sheets: `write_sheet_values` or `append_sheet_rows` (pass `path` or `googleId` from the create response). For Docs: `write_doc_content`/`append_doc_content`. For Slides: `write_slide_content`/`append_slides`. Read with `read_sheet_values`/`read_doc_content`/`read_slide_content`. Do NOT use inline `frame.write(content)` or hashline `frame.edit` to populate Google Workspace frames.\n\n**Write — content, binary, or Google Workspace frame:** ' + (isRemote ? 'Provide exactly one of `content` (HTML/markdown/text), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).' : 'Provide exactly one of `content` (HTML/markdown/text), `file_path` (absolute local file), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).') + ' Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for docs, sheets, and slides in that org. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. ' + (isRemote ? 'For binary frames (images, PDFs, videos), pass `base64` with the binary bytes.' : 'For binary frames (images, PDFs, videos), use `file_path` when the file is local to the MCP host, or `base64` when the caller already has binary bytes.') + '\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
2884
- projectId: PROJECT_OVERRIDE_PARAM,
2885
- action: z.enum(['read', 'write', 'set_state', 'get_state', 'write_sheet_values', 'read_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'get_sheet', 'update_sheet', 'get_doc', 'read_doc_content', 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc', 'get_slide', 'read_slide_content', 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide', 'create_office', 'read_office', 'edit_office', 'write_excalidraw', 'edit_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform. Use native Doc/Slide actions for Google Docs/Slides; do not use inline write/edit for native Workspace content.'),
2886
- path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
2887
- lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
2888
- content: z.string().optional().describe('[write] HTML/markdown/text for Drafted inline frames. [write_doc_content|append_doc_content] native Google Doc body text. Do not use action=write content to populate Google Doc/Slide frames.'),
2889
- excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene. EVERY write_excalidraw REPLACES THE WHOLE SCENE — it does not merge; to grow a diagram, either resend all prior elements plus the new ones, or use action="edit_excalidraw" (element-level upsert/remove by id) which needs only the changed elements. WRITE LEAN SCENES: your MCP client caps tool-call arguments (a big scene is rejected before Drafted ever sees it — once a scene exceeds that cap, edit it with edit_excalidraw, not write_excalidraw), so emit only the fields that carry meaning — id, type, x, y, width, height, angle, text/label, strokeColor, backgroundColor, fillStyle, strokeWidth, and the binding ids for arrows. Omit every field the editor can default (version, versionNonce, seed, updated, groupIds, boundElements when empty, roundness, opacity at 100, frameId when null). The editor fills them on open; shipping them can triple the payload for no gain.'),
2890
- elements: z.array(z.any()).optional().describe('[edit_excalidraw] Excalidraw elements to upsert by id: an element whose id already exists is shallow-merged (a partial {id,x,y} moves it, keeping its other props); a new id is appended. Send only the changed elements — the rest of the scene is preserved. Same lean-fields guidance as excalidraw_data.'),
2891
- remove: z.array(z.string()).optional().describe('[edit_excalidraw] Element ids to delete from the scene.'),
2892
- raw: z.boolean().optional().describe('[read] return the frame content as stored bytes (no NNNhash| line-number prefixes) — use when you need to parse the content as JSON (e.g. an Excalidraw scene). Note: edit ops require the hashline anchors, so omit raw when you intend to edit.'),
2893
- state: z.any().optional().describe('[set_state] App-frame state object (JSON) to persist for a deployed windowType:"app" frame — e.g. {specText:"..."} for the AS/NZS electrical app. The canvas hydrates the app from this on load (the host posts a "hydrate" message with it when the frame mounts), so you can deploy a generic app frame once and drive it with data afterwards. Max 64KB. Frame must be an app frame.'),
2894
- file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content/base64/googleType.'),
2895
- base64: z.string().optional().describe('[write] base64-encoded binary content. Mutually exclusive with content/file_path/googleType. Use with content_type when known.'),
2896
- content_type: z.string().optional().describe('[write + base64] MIME type for base64 binary content, e.g. image/png, application/pdf. Defaults from the path extension or application/octet-stream.'),
2897
- googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] Create or attach a native Google Workspace frame. Use with title to create new, or url/googleId to attach existing.'),
2898
- title: z.string().optional().describe('[write + googleType] Title for a new native file. Defaults to filename from path.'),
2899
- url: z.string().optional().describe('[write + googleType] Existing Google file URL to attach.'),
2900
- googleId: z.string().optional().describe('[write + googleType] Existing Google file ID to attach. [Sheet/Doc/Slide actions] Native Google file ID to use when not resolving from path/frame.'),
2901
- officeType: z.enum(['xlsx', 'docx']).optional().describe('[create_office] native Drafted-owned Office format to author: xlsx (spreadsheet) or docx (document). Real OOXML bytes — download is the file itself, no conversion.'),
2902
- rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[create_office xlsx] initial rows for a single-sheet xlsx (2D array of cell values).'),
2903
- sheets: z.array(z.object({ name: z.string().optional(), rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional() })).optional().describe('[create_office xlsx] multiple sheets: [{ name, rows }].'),
2904
- paragraphs: z.array(z.object({ html: z.string().optional(), text: z.string().optional(), style: z.string().optional(), align: z.string().optional(), list: z.string().optional() })).optional().describe('[create_office docx] document paragraphs: [{ html?, text?, style?, align?, list? }]. html uses the inline subset <b>,<i>,<u>,<s>,<span style="color:#c00;font-size:12pt;font-family:Arial">. style = Heading1..Heading6 or Title. align = left|center|right|justify. list = bullet|number.'),
2905
- sheet: z.string().optional().describe('[read_office xlsx] limit the read to one sheet by name.'),
2906
- maxCells: z.number().optional().describe('[read_office xlsx] cap non-empty cells returned (default 5000).'),
2907
- ops: z.array(z.object({
2908
- type: z.string(),
2909
- sheet: z.string().optional(),
2910
- ref: z.string().optional(),
2911
- value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
2912
- formula: z.string().optional(),
2913
- cells: z.array(z.object({ ref: z.string(), value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), formula: z.string().optional(), cellStyle: z.object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), fontSize: z.number().optional(), fontName: z.string().optional(), color: z.string().optional(), bg: z.string().optional(), align: z.string().optional(), valign: z.string().optional(), numFmt: z.string().optional(), border: z.boolean().optional() }).optional() })).optional(),
2914
- range: z.string().optional(),
2915
- name: z.string().optional(),
2916
- from: z.string().optional(),
2917
- to: z.string().optional(),
2918
- rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional(),
2919
- id: z.string().optional(),
2920
- html: z.string().optional(),
2921
- text: z.string().optional(),
2922
- find: z.string().optional(),
2923
- replace: z.string().optional(),
2924
- all: z.boolean().optional(),
2925
- start: z.number().optional(),
2926
- end: z.number().optional(),
2927
- style: z.string().optional(),
2928
- align: z.string().optional(),
2929
- list: z.string().optional(),
2930
- header: z.boolean().optional(),
2931
- col: z.string().optional(),
2932
- width: z.number().optional(),
2933
- columns: z.array(z.object({ col: z.string(), width: z.number() })).optional(),
2934
- cellStyle: z.object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), fontSize: z.number().optional(), fontName: z.string().optional(), color: z.string().optional(), bg: z.string().optional(), align: z.string().optional(), valign: z.string().optional(), numFmt: z.string().optional(), border: z.boolean().optional() }).optional(),
2935
- after: z.string().optional(),
2936
- before: z.string().optional(),
2937
- attrs: z.object({ b: z.boolean().optional(), i: z.boolean().optional(), u: z.boolean().optional(), strike: z.boolean().optional(), color: z.string().optional(), size: z.number().optional(), font: z.string().optional() }).optional(),
2938
- })).optional().describe('[edit_office] edit ops, resolved against stable addresses (no index drift). xlsx: {type:"setCell",sheet?,ref,value|formula,cellStyle?} | {type:"setCells",sheet?,cells:[{ref,value|formula,cellStyle?}]} | {type:"styleRange",sheet?,range,cellStyle} | {type:"setColumnWidth",sheet?,columns:[{col,width}]} | {type:"clearRange",sheet?,range} | {type:"addSheet",name,rows?} | {type:"renameSheet",from,to} | {type:"deleteSheet",name}. cellStyle:{bold,italic,underline,fontSize,fontName,color,bg,align,valign,numFmt,border}. docx (id = paraId from read_office): {type:"setParagraph",id,html|text,style?,align?,list?} | {type:"format",id,start,end,attrs:{b,i,u,strike,color,size,font}} (run-level char range) | {type:"replaceText",id,find,replace,all?} | {type:"setStyle",id,style?,align?,list?} | {type:"insertParagraph",after|before,html|text,style?,align?,list?} | {type:"insertTable",after|before,rows:[[...]],header?} | {type:"deleteParagraph",id}. align=left|center|right|justify; list=bullet|number.'),
2939
- format: z.enum(['plain_text', 'markdown']).optional().describe('[write_doc_content|append_doc_content] Source format hint. Currently plain_text and minimal markdown are accepted as text.'),
2940
- mode: z.enum(['replace', 'append', 'clear']).optional().describe('[Doc/Slide content actions] Optional mode hint for clients; prefer the explicit write/append/clear action names.'),
2941
- slides: z.array(z.object({
2942
- title: z.string().optional(),
2943
- bullets: z.array(z.string()).optional(),
2944
- speakerNotes: z.string().optional(),
2945
- layout: z.string().optional(),
2946
- })).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
2947
- requests: z.array(z.any()).optional().describe('[update_doc|update_slide] Raw Google Docs/Slides batchUpdate requests for advanced updates only; common Doc/Slide population should use write_doc_content/append_doc_content/write_slide_content/append_slides.'),
2948
- slideObjectIds: z.array(z.string()).optional().describe('[clear_slides] Optional slide object IDs to delete. Omit to clear all slides.'),
2949
- range: z.string().optional().describe('[Sheet value actions] A1 range, e.g. Sheet1!A1 or Data!A:Z.'),
2950
- values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values (each cell: string, number, boolean, or null).'),
2951
- valueInputOption: z.enum(['RAW', 'USER_ENTERED']).optional().describe('[write_sheet_values|append_sheet_rows] Google Sheets value input option. Defaults to USER_ENTERED.'),
2952
- majorDimension: z.enum(['ROWS', 'COLUMNS']).optional().describe('[Sheet value actions] Major dimension for values. Defaults to ROWS when writing/appending.'),
2953
- valueRenderOption: z.enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']).optional().describe('[read_sheet_values] How values should be rendered. Defaults to Google Sheets API default.'),
2954
- dateTimeRenderOption: z.enum(['SERIAL_NUMBER', 'FORMATTED_STRING']).optional().describe('[read_sheet_values] How dates/times should be rendered.'),
2955
- insertDataOption: z.enum(['OVERWRITE', 'INSERT_ROWS']).optional().describe('[append_sheet_rows] How new rows are inserted. Defaults to INSERT_ROWS.'),
2956
- operation: z.enum(['add_sheet', 'rename_sheet']).optional().describe('[update_sheet] Sheet tab operation.'),
2957
- sheetTitle: z.string().optional().describe('[update_sheet add_sheet] Title for the new sheet tab.'),
2958
- sheetId: z.number().optional().describe('[update_sheet rename_sheet] Numeric sheet/tab id.'),
2959
- newTitle: z.string().optional().describe('[update_sheet rename_sheet] New title for the sheet tab.'),
2960
- autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
2961
- width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
2962
- height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
2963
- color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
2964
- operations: z.array(z.object({
2965
- type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
2966
- lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash "vix": that is rejected, since the same hash can recur on multiple lines.'),
2967
- newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
2968
- })).optional().describe('[edit] hashline edit operations'),
2969
- from: z.string().optional().describe('[mv] source path: /{layer}/{lane}/{filename} for a frame, /{layer}/{lane} for a lane, /{layer} for a whole layer (lane/layer require toProjectId)'),
2970
- to: z.string().optional().describe('[mv] destination path /{layer}/{lane}/{filename} — the layer segment is always required and explicit'),
2971
- toProjectId: z.string().optional().describe('[mv] move ACROSS projects: destination project UUID/slug/name. Same-org only. Frame ids and version history follow; frame-scoped shares, public links, and lane share tokens are NOT carried into the destination project. Omit for the classic in-project rename/move.'),
2972
- dryRun: z.boolean().optional().describe('[mv] preview the move without applying it; returns the resolved frame and current path so you can confirm before retrying with dryRun=false.'),
2973
- anchored: z.boolean().optional().describe('[anchor] true to anchor, false to unanchor. Anchored frames MUST be read before writing/editing in the same layer.'),
2974
- query: z.string().optional().describe('[search] term to match against frame names'),
2975
- limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
2976
- versionId: z.string().optional().describe('[read_version|restore_version] version id'),
2977
- reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
2978
- }, async (args) => {
2979
- try {
2980
- const { action } = args;
2981
- // G1: don't edit frame content before the session has searched the wiki.
2982
- // When blocking, auto-inject the (bounded) wiki index so the search lands.
2983
- if (G1_MUTATING_FRAME_ACTIONS.has(action)) {
2984
- const gs = getSessionState().gates;
2985
- if (!gs.wikiSearched) {
2986
- let wikiIndex = '';
2987
- try { const tree = await api('GET', '/api/wiki/tree'); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* index best-effort */ }
2988
- return err(new Error(g1Block(gs, wikiIndex)));
2989
- }
2990
- }
2991
- // Echo project context on every mutation so the agent sees where the
2992
- // write landed before it can develop a wrong assumption.
2993
- const projectCtx = getCurrentProjectContext();
2994
- const withProject = (result) => ({ ...result, project: projectCtx });
2995
- const workspaceBody = (kind) => {
2996
- const { path, googleId } = args;
2997
- const body = {};
2998
- const frameUrlMatch = path?.match(/\/f\/([a-f0-9-]{36})/);
2999
- const uuidMatch = path?.match(/^[a-f0-9-]{36}$/);
3000
- if (googleId) {
3001
- if (kind === 'sheet') body.spreadsheetId = googleId;
3002
- else if (kind === 'doc') body.documentId = googleId;
3003
- else body.presentationId = googleId;
3004
- } else if (frameUrlMatch?.[1] || uuidMatch) {
3005
- body.frameId = frameUrlMatch?.[1] || path;
3006
- } else if (path) {
3007
- body.path = path;
3008
- body.projectId = getState().projectId;
3009
- } else {
3010
- const label = kind === 'sheet' ? 'Google Sheet' : kind === 'doc' ? 'Google Doc' : 'Google Slide';
3011
- throw new Error(`Provide path to a ${label} frame, frame ID/URL, or googleId/native file ID`);
3012
- }
3013
- return body;
3014
- };
3015
- // Resolve a frame path / URL / UUID to a frame id for the native Office routes.
3016
- const resolveOfficeFrameId = async (path) => {
3017
- if (!path) throw new Error('Provide path to an office frame, frame URL, or frame ID');
3018
- const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
3019
- const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
3020
- if (frameUrlMatch?.[1] || uuidMatch) return frameUrlMatch?.[1] || path;
3021
- const parts = path.replace(/^\/+/, '').split('/');
3022
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
3023
- const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
3024
- const id = frame?.id || frame?.frame?.id;
3025
- if (!id) throw new Error('Office frame not found for path');
3026
- return id;
3027
- };
3028
- switch (action) {
3029
- case 'read': {
3030
- const { path, lines, raw } = args;
3031
- if (!path) throw new Error('path required for action=read');
3032
- const qs = [];
3033
- if (lines) qs.push(`lines=${encodeURIComponent(lines)}`);
3034
- if (raw) qs.push('raw=1');
3035
- const query = qs.length ? `?${qs.join('&')}` : '';
3036
- const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
3037
- const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
3038
- const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
3039
- let result;
3040
- if (frameId) {
3041
- result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
3042
- } else {
3043
- const parts = path.replace(/^\/+/, '').split('/');
3044
- // 2 segments = a layer-root frame (empty lane, e.g. /designs/AGENTS.md);
3045
- // 3 = /{layer}/{lane}/{filename}. Both resolve server-side.
3046
- if (parts.length !== 2 && parts.length !== 3) throw new Error('Path must be /{layer}/{filename}, /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
3047
- result = await api('GET', `/api/fs/${parts.map(encodeURIComponent).join('/')}${query}`);
3048
- }
3049
- // Surface content as the visible text. Some Claude clients prefer
3050
- // structuredContent over text when both are present and structured looks
3051
- // "complete" — so include content in BOTH places to ensure the agent
3052
- // can always see the frame's actual content, not just metadata.
3053
- const structured = frameStructuredContent(result, projectCtx);
3054
- structured.content = result.content ?? '';
3055
- structured.size = result.size;
3056
- structured.totalLines = result.totalLines;
3057
- const visibleText = JSON.stringify({
3058
- path: result.path,
3059
- contentType: result.contentType,
3060
- size: result.size,
3061
- totalLines: result.totalLines,
3062
- content: result.content ?? '',
3063
- }, null, 2);
3064
- return ok(visibleText, {
3065
- structuredContent: structured,
3066
- _meta: { frameHtml: result.content },
3067
- });
3068
- }
3069
- case 'set_state':
3070
- case 'get_state': {
3071
- // Read/write the persisted appState of a deployed windowType:'app' frame
3072
- // (designs.metadata.appState via /api/file/:id/state). The canvas hydrates
3073
- // the app from this on load — so a generic app frame can be driven with
3074
- // data after deploy (e.g. push {specText} to the AS/NZS electrical app).
3075
- const { path } = args;
3076
- if (!path) throw new Error(`path required for action=${action}`);
3077
- const urlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
3078
- const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
3079
- let frameId = urlMatch?.[1] || (uuidMatch ? path : null);
3080
- if (!frameId) {
3081
- const parts = path.replace(/^\/+/, '').split('/');
3082
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
3083
- const f = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
3084
- frameId = f.id;
3085
- if (!frameId) throw new Error(`Could not resolve a frame id for ${path}`);
3086
- }
3087
- if (action === 'get_state') {
3088
- const r = await api('GET', `/api/file/${frameId}/state`);
3089
- return ok(JSON.stringify({ frameId, state: r.state ?? null }, null, 2));
3090
- }
3091
- if (args.state === undefined) throw new Error('state required for action=set_state — the app-frame state object to persist; the app hydrates from it on load');
3092
- await api('POST', `/api/file/${frameId}/state`, { state: args.state });
3093
- return ok(`App state persisted to frame ${frameId}. The app hydrates from it on next load — open or refresh the frame to see it render with the new state.`);
3094
- }
3095
- case 'get_sheet':
3096
- case 'read_sheet_values':
3097
- case 'write_sheet_values':
3098
- case 'append_sheet_rows':
3099
- case 'clear_sheet_range':
3100
- case 'update_sheet': {
3101
- const { path, googleId, range, values, valueInputOption, majorDimension, valueRenderOption, dateTimeRenderOption, insertDataOption, operation, sheetTitle, sheetId, newTitle } = args;
3102
- const body = workspaceBody('sheet');
3103
- if (range) body.range = range;
3104
- if (majorDimension) body.majorDimension = majorDimension;
3105
- if (action === 'write_sheet_values' || action === 'append_sheet_rows') {
3106
- if (!values || !Array.isArray(values)) throw new Error(`values required for action=${action}`);
3107
- body.values = values;
3108
- body.valueInputOption = valueInputOption || 'USER_ENTERED';
3109
- body.majorDimension = majorDimension || 'ROWS';
3110
- }
3111
- if (action === 'read_sheet_values') {
3112
- if (valueRenderOption) body.valueRenderOption = valueRenderOption;
3113
- if (dateTimeRenderOption) body.dateTimeRenderOption = dateTimeRenderOption;
3114
- }
3115
- if (action === 'append_sheet_rows') body.insertDataOption = insertDataOption || 'INSERT_ROWS';
3116
- if (action === 'update_sheet') {
3117
- body.operation = operation;
3118
- if (sheetTitle) body.sheetTitle = sheetTitle;
3119
- if (sheetId != null) body.sheetId = sheetId;
3120
- if (newTitle) body.newTitle = newTitle;
3121
- }
3122
- const endpoint = action === 'get_sheet'
3123
- ? '/api/google/workspace/sheet'
3124
- : action === 'read_sheet_values'
3125
- ? '/api/google/workspace/sheet-values/read'
3126
- : action === 'write_sheet_values'
3127
- ? '/api/google/workspace/sheet-values'
3128
- : action === 'append_sheet_rows'
3129
- ? '/api/google/workspace/sheet-values/append'
3130
- : action === 'clear_sheet_range'
3131
- ? '/api/google/workspace/sheet-values/clear'
3132
- : '/api/google/workspace/sheet-update';
3133
- const result = await api('POST', endpoint, body);
3134
- return ok(withProject(result));
3135
- }
3136
- case 'get_doc':
3137
- case 'read_doc_content':
3138
- case 'write_doc_content':
3139
- case 'append_doc_content':
3140
- case 'clear_doc_content':
3141
- case 'update_doc': {
3142
- const { content, format, mode, requests } = args;
3143
- const body = workspaceBody('doc');
3144
- if (format) body.format = format;
3145
- if (mode) body.mode = mode;
3146
- if (action === 'write_doc_content' || action === 'append_doc_content') {
3147
- if (typeof content !== 'string') throw new Error(`content string required for action=${action}`);
3148
- body.content = content;
3149
- }
3150
- if (action === 'update_doc') {
3151
- if (!Array.isArray(requests)) throw new Error('requests array required for action=update_doc');
3152
- body.requests = requests;
3153
- }
3154
- const endpoint = action === 'get_doc'
3155
- ? '/api/google/workspace/doc'
3156
- : action === 'read_doc_content'
3157
- ? '/api/google/workspace/doc-content/read'
3158
- : action === 'write_doc_content'
3159
- ? '/api/google/workspace/doc-content'
3160
- : action === 'append_doc_content'
3161
- ? '/api/google/workspace/doc-content/append'
3162
- : action === 'clear_doc_content'
3163
- ? '/api/google/workspace/doc-content/clear'
3164
- : '/api/google/workspace/doc-update';
3165
- const result = await api('POST', endpoint, body);
3166
- return ok(withProject(result));
3167
- }
3168
- case 'get_slide':
3169
- case 'read_slide_content':
3170
- case 'write_slide_content':
3171
- case 'append_slides':
3172
- case 'clear_slides':
3173
- case 'update_slide': {
3174
- const { slides, requests, slideObjectIds, mode } = args;
3175
- const body = workspaceBody('slide');
3176
- if (mode) body.mode = mode;
3177
- if (action === 'write_slide_content' || action === 'append_slides') {
3178
- if (!Array.isArray(slides)) throw new Error(`slides array required for action=${action}`);
3179
- body.slides = slides;
3180
- }
3181
- if (action === 'clear_slides' && Array.isArray(slideObjectIds)) body.slideObjectIds = slideObjectIds;
3182
- if (action === 'update_slide') {
3183
- if (!Array.isArray(requests)) throw new Error('requests array required for action=update_slide');
3184
- body.requests = requests;
3185
- }
3186
- const endpoint = action === 'get_slide'
3187
- ? '/api/google/workspace/slide'
3188
- : action === 'read_slide_content'
3189
- ? '/api/google/workspace/slide-content/read'
3190
- : action === 'write_slide_content'
3191
- ? '/api/google/workspace/slide-content'
3192
- : action === 'append_slides'
3193
- ? '/api/google/workspace/slides/append'
3194
- : action === 'clear_slides'
3195
- ? '/api/google/workspace/slides/clear'
3196
- : '/api/google/workspace/slide-update';
3197
- const result = await api('POST', endpoint, body);
3198
- return ok(withProject(result));
3199
- }
3200
- case 'create_office': {
3201
- // Author a native Drafted-owned Office file (real bytes; download is the
3202
- // file itself). Populate via edit_office; read structure via read_office.
3203
- const { path, title, officeType = 'xlsx', rows, sheets, paragraphs } = args;
3204
- let layer = 'copy';
3205
- let lane = 'default';
3206
- let label = title;
3207
- if (path) {
3208
- const p = path.replace(/^\/+/, '').split('/');
3209
- if (p.length === 3) { layer = p[0]; lane = p[1]; label = p[2]; }
3210
- }
3211
- if (!label) throw new Error('Provide title (filename) or a /{layer}/{lane}/{filename} path for the new office file');
3212
- return ok(await api('POST', '/api/office/create', { projectId: getState().projectId, layer, lane, label, format: officeType, rows, sheets, paragraphs }));
3213
- }
3214
- case 'read_office': {
3215
- const id = await resolveOfficeFrameId(args.path);
3216
- const q = [];
3217
- if (args.sheet) q.push('sheet=' + encodeURIComponent(args.sheet));
3218
- if (args.maxCells) q.push('maxCells=' + Number(args.maxCells));
3219
- return ok(await api('GET', `/api/office/${id}${q.length ? '?' + q.join('&') : ''}`));
3220
- }
3221
- case 'edit_office': {
3222
- if (!Array.isArray(args.ops) || !args.ops.length) throw new Error('ops (non-empty array) required for action=edit_office');
3223
- const id = await resolveOfficeFrameId(args.path);
3224
- return ok(await api('POST', `/api/office/${id}/edit`, { ops: args.ops }));
3225
- }
3226
- case 'write': {
3227
- const { path, content, file_path, base64, content_type, autoSize, width, height, color, googleType, title, url, googleId } = args;
3228
- if (!path) throw new Error('path required for action=write');
3229
- const writeSources = [content != null, !!file_path, base64 != null, !!googleType].filter(Boolean).length;
3230
- if (writeSources > 1) throw new Error('Provide only one of content, file_path, base64, or googleType');
3231
- if (writeSources === 0) throw new Error('Provide content, file_path, base64, or googleType');
3232
- const parts = path.replace(/^\/+/, '').split('/');
3233
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
3234
- if (googleType) {
3235
- const projectId = getState().projectId;
3236
- if (!projectId) throw new Error('Open a project first with project(action="open") before creating Google Workspace frames');
3237
- const label = basename(parts[2], extname(parts[2])) || parts[2];
3238
- const body = {
3239
- projectId,
3240
- layer: parts[0],
3241
- lane: parts[1],
3242
- label,
3243
- type: googleType,
3244
- width,
3245
- height,
3246
- };
3247
- const result = url || googleId
3248
- ? await api('POST', '/api/google/workspace/frame', { ...body, url, googleId })
3249
- : await api('POST', '/api/google/workspace/create-frame', { ...body, title: title || label });
3250
- return ok(withProject(withFrameBreadcrumb({
3251
- ...result,
3252
- path,
3253
- label,
3254
- contentType: 'text/html',
3255
- sourceType: googleType,
3256
- }, { hint: true })), {
3257
- structuredContent: frameStructuredContent({ ...result, path, label, contentType: 'text/html' }, projectCtx),
3258
- });
3259
- }
3260
- let body;
3261
- if (file_path) {
3262
- const resolved = resolve(file_path);
3263
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
3264
- const ext = extname(resolved).toLowerCase();
3265
- const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
3266
- if (TEXT_EXTS.includes(ext)) {
3267
- body = { content: readFileSync(resolved, 'utf8') };
3268
- if (autoSize) body.autoSize = true;
3269
- } else {
3270
- const buffer = readFileSync(resolved);
3271
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.m4v': 'video/x-m4v' };
3272
- body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
3273
- }
3274
- } else if (base64 != null) {
3275
- body = { base64, contentType: content_type || mimeFromExt(extname(parts[2])) };
3276
- } else {
3277
- body = { content };
3278
- if (autoSize) body.autoSize = true;
3279
- }
3280
- if (width) body.width = width;
3281
- if (height) body.height = height;
3282
- if (color) body.color = color;
3283
- const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
3284
- return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
3285
- structuredContent: frameStructuredContent(result, projectCtx),
3286
- _meta: body.content ? { frameHtml: body.content } : undefined,
3287
- });
3288
- }
3289
- case 'write_excalidraw': {
3290
- const { path, excalidraw_data, width, height, color } = args;
3291
- if (!path) throw new Error('path required for action=write_excalidraw');
3292
- const parts = path.replace(/^\/+/, '').split('/');
3293
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
3294
- const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
3295
- const scene = excalidraw_data ?? emptyExcalidrawScene();
3296
- const body = { content: stringifyExcalidrawScene(scene) };
3297
- if (width) body.width = width;
3298
- if (height) body.height = height;
3299
- if (color) body.color = color;
3300
- const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
3301
- return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
3302
- structuredContent: frameStructuredContent(result, projectCtx),
3303
- _meta: { frameHtml: body.content },
3304
- });
3305
- }
3306
- case 'edit_excalidraw': {
3307
- const { path, elements, remove } = args;
3308
- if (!path) throw new Error('path required for action=edit_excalidraw');
3309
- const parts = path.replace(/^\/+/, '').split('/');
3310
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
3311
- const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
3312
- if (!Array.isArray(elements) && !Array.isArray(remove)) {
3313
- throw new Error('Provide elements[] to add/update and/or remove[] element ids');
3314
- }
3315
- const result = await api('POST', '/api/fs/edit-excalidraw', { path: `/${parts[0]}/${parts[1]}/${filename}`, elements, remove });
3316
- return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
3317
- structuredContent: frameStructuredContent(result, projectCtx),
3318
- });
3319
- }
3320
- case 'edit': {
3321
- const { path, operations } = args;
3322
- if (!path) throw new Error('path required for action=edit');
3323
- if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
3324
- const result = await api('POST', '/api/fs/edit', { path, operations });
3325
- return ok(withProject(result), {
3326
- structuredContent: frameStructuredContent(result, projectCtx),
3327
- _meta: result.content ? { frameHtml: result.content } : undefined,
3328
- });
3329
- }
3330
- case 'mv': {
3331
- const { from, to, toProjectId, dryRun = false } = args;
3332
- if (!from || !to) throw new Error('from and to required for action=mv');
3333
- if (dryRun) {
3334
- // Resolve current frame at `from`. Don't apply the rename, just
3335
- // confirm the source exists and report what `to` will become.
3336
- const current = await api('GET', `/api/fs?path=${encodeURIComponent(from)}`);
3337
- return ok(withProject({
3338
- dryRun: true,
3339
- from,
3340
- to,
3341
- toProjectId: toProjectId || undefined,
3342
- currentFrame: current?.frame || current,
3343
- note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the move.',
3344
- }));
3345
- }
3346
- return ok(withProject(await api('POST', '/api/fs/mv', { from, to, ...(toProjectId ? { toProjectId } : {}) })));
3347
- }
3348
- case 'anchor': {
3349
- const { path, anchored } = args;
3350
- if (!path) throw new Error('path required for action=anchor');
3351
- if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
3352
- // Cap (G5): anchoring adds the frame's body to the project's required-reading
3353
- // set — reject if it would push the project past the per-project budget.
3354
- if (anchored === true) {
3355
- const used = await getProjectPrimingSize(getState().projectId);
3356
- const addSize = await getFrameContentSize(path);
3357
- if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this anchored frame')));
3358
- }
3359
- return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
3360
- }
3361
-
3362
- case 'versions': {
3363
- const { path } = args;
3364
- if (!path) throw new Error('path required for action=versions');
3365
- const result = await api('GET', `/api/fs/versions?path=${encodeURIComponent(path)}`);
3366
- return ok(withProject(result));
3367
- }
3368
- case 'read_version': {
3369
- const { versionId } = args;
3370
- if (!versionId) throw new Error('versionId required for action=read_version');
3371
- const result = await api('GET', `/api/fs/versions/${versionId}`);
3372
- return ok(withProject(result));
3373
- }
3374
- case 'restore_version': {
3375
- const { versionId, reason } = args;
3376
- if (!versionId) throw new Error('versionId required for action=restore_version');
3377
- const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
3378
- return ok(withProject(result));
3379
- }
3380
- case 'search': {
3381
- const { query, projectId, limit = 50 } = args;
3382
- if (!query) throw new Error('query required for action=search');
3383
- const params = new URLSearchParams({ q: query });
3384
- // projectId may be a slug/name (the shared override param) — the tool() seam has
3385
- // already resolved it into state, so take the resolved UUID from there.
3386
- if (projectId) params.set('projectId', getState().projectId || projectId);
3387
- await ensureSession();
3388
- const url = `${getServerUrl()}/api/search?${params.toString()}`;
3389
- const res = await fetch(url, { headers: getAuthHeaders() });
3390
- if (!res.ok) throw new Error(`Search failed: ${res.status}`);
3391
- const results = await res.json();
3392
- const cap = Math.min(Math.max(1, limit || 50), 200);
3393
- const slice = results.slice(0, cap);
3394
- return ok({
3395
- results: slice.map(r => ({
3396
- id: r.id,
3397
- path: `/${r.layer}/${r.lane}/${r.label}`,
3398
- project: r.projectName,
3399
- projectId: r.projectId,
3400
- frameUrl: semanticFrameUrl(r) || `${getServerUrl()}/f/${r.id}`,
3401
- contentType: r.contentType,
3402
- updatedAt: r.updatedAt,
3403
- })),
3404
- totalAvailable: results.length,
3405
- truncated: results.length > cap,
3406
- });
3407
- }
3408
- default:
3409
- throw new Error(`Unknown frame action: ${action}`);
3410
- }
3411
- } catch (error) { return err(error); }
3412
- });
3413
-
3414
- tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
3415
- projectId: PROJECT_OVERRIDE_PARAM,
3416
- path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID); layer entries include layerUrl and lane entries laneUrl — relay those to the user instead of the project URL when your work was scoped to one layer or lane.'),
3417
- recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
3418
- summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
3419
- pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
3420
- head: z.number().optional().describe('Include the first N lines of EACH frame inline (max 50). Survey a whole lane in one call instead of one frame(action="read") per frame — read only the frames the heads show you actually need.'),
3421
- limit: z.number().optional().default(500).describe('Max entries to return (default 500, max 2000). Use pattern or path to scope further.'),
3422
- }, async ({ path, recursive, summary, pattern, head, limit }) => {
3423
- try {
3424
- const params = new URLSearchParams();
3425
- params.set('path', path);
3426
- // At layer depth (/designs, /wireframes, etc.) auto-recurse so frames in sublanes
3427
- // are always visible — without this, agents only see root frames and miss any frame
3428
- // whose lane != "". Recursive walks force summary mode to keep payloads small.
3429
- const cleanPath = (path || '/').replace(/^\/+|\/+$/g, '');
3430
- const pathDepth = cleanPath ? cleanPath.split('/').length : 0;
3431
- if (recursive || pathDepth === 1) {
3432
- params.set('recursive', 'true');
3433
- params.set('summary', 'true');
3434
- } else if (summary) {
3435
- params.set('summary', 'true');
3436
- }
3437
- if (head) params.set('head', String(head));
3438
- if (pattern) params.set('pattern', pattern);
3439
- const result = await api('GET', `/api/fs/?${params.toString()}`);
3440
-
3441
- // Cap entries client-side as a safety net
3442
- const cap = Math.min(Math.max(1, limit || 500), 2000);
3443
- if (Array.isArray(result?.entries) && result.entries.length > cap) {
3444
- result.totalAvailable = result.entries.length;
3445
- result.truncated = true;
3446
- result.entries = result.entries.slice(0, cap);
3447
- }
3448
-
3449
- // ChatGPT Apps SDK: canvas-overview widget renders byLayer.
3450
- // /api/fs/ entries are { path, type: 'layer'|'lane'|'frame', id, title, size,
3451
- // updatedAt, color, frameUrl } — there are no layer/lane/name/label fields, so
3452
- // derive them from the path. Skip non-frame entries (lanes/layers are structural
3453
- // and would otherwise serialize as empty objects under "unsorted").
3454
- const entries = Array.isArray(result?.entries) ? result.entries : [];
3455
- const byLayer = {};
3456
- for (const e of entries) {
3457
- if (e.type && e.type !== 'frame') continue;
3458
- const segs = String(e.path || '').replace(/^\/+/, '').split('/').filter(Boolean);
3459
- if (!segs.length) continue;
3460
- const layer = segs[0];
3461
- const filename = segs[segs.length - 1];
3462
- const lane = segs.length >= 3 ? segs.slice(1, -1).join('/') : undefined;
3463
- (byLayer[layer] ??= []).push({
3464
- label: e.title || filename,
3465
- filename,
3466
- title: e.title,
3467
- layer,
3468
- lane,
3469
- id: e.id,
3470
- frameUrl: e.frameUrl || (e.id ? `${getServerUrl()}/f/${e.id}` : undefined),
3471
- });
3472
- }
3473
- const projectId = getState().projectId;
3474
- const structuredContent = {
3475
- project: result?.project || result?.projectName,
3476
- canvasUrl: result?.project?.slug
3477
- ? (result.project.orgSlug
3478
- ? `${getServerUrl()}/o/${encodeURIComponent(result.project.orgSlug)}/${encodeURIComponent(result.project.slug)}`
3479
- : `${getServerUrl()}/project/${encodeURIComponent(result.project.slug)}`)
3480
- : undefined,
3481
- byLayer,
3482
- truncated: result?.truncated || false,
3483
- totalAvailable: result?.totalAvailable,
3484
- };
3485
- return ok(result, { structuredContent });
3486
- } catch (error) { return err(error); }
3487
- });
3488
-
3489
- tool('rm', 'Delete a frame or lane. Defaults to the ACTIVE PROJECT; pass projectId to delete in another project, or frameId to delete a frame by UUID from anywhere (no project binding needed — the frame\'s own org/project is authoritative). Response includes "project" so you see where the deletion landed.', {
3490
- projectId: PROJECT_OVERRIDE_PARAM,
3491
- path: z.string().optional().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane). Mutually exclusive with frameId.'),
3492
- frameId: z.string().optional().describe('Frame UUID to delete, resolved independently of the active project — the one-call cleanup for a frame that landed in the wrong place. Mutually exclusive with path.'),
3493
- }, async ({ path, frameId }) => {
3494
- try {
3495
- if (frameId && path) throw new Error('Pass either path or frameId, not both.');
3496
- if (!frameId && !path) throw new Error('rm needs a path or a frameId.');
3497
- // A frame UUID self-derives its org and project server-side, so this works from an
3498
- // unbound session and across projects — no open/re-open dance to remove a stray frame.
3499
- if (frameId) return ok(await api('DELETE', `/api/designs/${encodeURIComponent(frameId)}`));
3500
- const clean = path.replace(/^\/+|\/+$/g, '');
3501
- const result = await api('DELETE', `/api/fs/${clean}`);
3502
- return ok({ ...result, project: getCurrentProjectContext() });
3503
- } catch (error) { return err(error); }
3504
- });
3505
-
3506
- // batch tool temporarily disabled — keep code intact for re-enablement later.
3507
- /*
3508
- tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use project(action="open") first if needed.', {
3509
- operations: z.array(z.object({
3510
- tool: z.enum(['write', 'rm', 'mv', 'edit', 'upload_asset']).describe('Tool to execute'),
3511
- path: z.string().optional().describe('Path (for write, rm, edit)'),
3512
- content: z.string().optional().describe(`Content (for write).${isRemote ? '' : ' Mutually exclusive with file_path.'}`),
3513
- // file_path is local-only — omitted on remote transports (web MCP).
3514
- ...(isRemote ? {} : { file_path: z.string().optional().describe('Absolute path to a local file to upload (for write, upload_asset). Mutually exclusive with content.') }),
3515
- color: z.string().optional().describe('CSS color for frame border (for write)'),
3516
- from: z.string().optional().describe('Source path (for mv)'),
3517
- to: z.string().optional().describe('Destination path (for mv)'),
3518
- operations: z.array(z.object({
3519
- type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
3520
- lineHash: z.string().describe('The full line anchor from read output — line number + 3-char hash, e.g. "182vix". NOT the bare hash.'),
3521
- newContent: z.string().optional(),
3522
- })).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vix".'),
3523
- asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
3524
- content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
3525
- frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
3526
- })).describe('ALWAYS use batch instead of multiple individual tool calls. Applies the same change to many files, writes multiple frames, or combines writes+edits+deletes. One canvas refresh instead of many. Example: editing 5 wireframes to remove a section = one batch with 5 edit operations.'),
3527
- }, async ({ operations }) => {
3528
- try {
3529
- // Collect all layers involved in the batch
3530
- const layers = new Set();
3531
- for (const op of operations) {
3532
- if (op.path) layers.add(parseLayer(op.path));
3533
- if (op.from) layers.add(parseLayer(op.from));
3534
- if (op.to) layers.add(parseLayer(op.to));
3535
- }
3536
- for (const layer of layers) {
3537
- }
3538
-
3539
- // Resolve file_path → base64 for write operations before sending to server
3540
- // Separate asset uploads from frame operations
3541
- const assetOps = operations.filter(op => op.tool === 'upload_asset');
3542
- const frameOps = operations.filter(op => op.tool !== 'upload_asset');
3543
-
3544
- const results = [];
3545
-
3546
- // Handle asset uploads via the asset API
3547
- for (const op of assetOps) {
3548
- try {
3549
- if (!op.asset_path) throw new Error('asset_path is required for upload_asset');
3550
- if (op.asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
3551
-
3552
- let b64, ct;
3553
- if (op.file_path) {
3554
- const resolved = resolve(op.file_path);
3555
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
3556
- const buffer = readFileSync(resolved);
3557
- b64 = buffer.toString('base64');
3558
- ct = op.content_type || mimeFromExt(extname(resolved));
3559
- } else if (op.content != null) {
3560
- b64 = Buffer.from(op.content).toString('base64');
3561
- ct = op.content_type || mimeFromExt(extname(op.asset_path));
3562
- } else {
3563
- throw new Error('upload_asset requires file_path or content');
3564
- }
3565
-
3566
- const body = { base64: b64, contentType: ct };
3567
- if (op.frame_id) body.frameId = op.frame_id;
3568
-
3569
- const projectId = getState().projectId;
3570
- if (!projectId) throw new Error('No active project. Call project(action="open") first.');
3571
- const r = await api('PUT', `/api/projects/${projectId}/assets/${op.asset_path}`, body);
3572
- results.push({ ok: true, tool: 'upload_asset', asset_path: op.asset_path, ...r });
3573
- } catch (e) {
3574
- results.push({ ok: false, tool: 'upload_asset', asset_path: op.asset_path, error: e.message });
3575
- }
3576
- }
3577
-
3578
- // Handle frame operations via the batch API
3579
- if (frameOps.length > 0) {
3580
- const TEXT_EXTS = ['.html', '.htm', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml', '.excalidraw'];
3581
- const resolvedOps = frameOps.map(op => {
3582
- if (op.tool === 'write' && op.file_path) {
3583
- const resolved = resolve(op.file_path);
3584
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
3585
- const ext = extname(resolved).toLowerCase();
3586
- const { file_path: _, ...rest } = op;
3587
- if (TEXT_EXTS.includes(ext)) {
3588
- return { ...rest, content: readFileSync(resolved, 'utf8') };
3589
- }
3590
- const buffer = readFileSync(resolved);
3591
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
3592
- return { ...rest, base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
3593
- }
3594
- return op;
3595
- });
3596
-
3597
- const batchResult = await api('POST', '/api/fs/batch', { operations: resolvedOps });
3598
- if (batchResult.results) {
3599
- for (const r of batchResult.results) {
3600
- results.push(withFrameBreadcrumb(r));
3601
- }
3602
- }
3603
- }
3604
-
3605
- return ok({ ok: true, results });
3606
- } catch (error) { return err(error); }
3607
- });
3608
- */
3609
-
3610
- // ── Asset tools ──────────────────────────────────────────────────
3611
-
3612
- tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PROJECT. Assets are referenced by frames via relative paths — e.g., if your HTML has <link href="css/styles.css">, upload with asset_path="css/styles.css". Assets are NOT frames — they don\'t appear on the canvas. `action=upload` to add/replace, `action=list` to browse, `action=rm` to delete.', {
3613
- projectId: PROJECT_OVERRIDE_PARAM,
3614
- action: z.enum(['upload', 'list', 'rm']).describe('Operation to perform.'),
3615
- asset_path: z.string().optional().describe('[upload] relative asset path (e.g. "css/styles.css"). Must match the path used in HTML references.'),
3616
- file_path: z.string().optional().describe('[upload] absolute path to a local file. Mutually exclusive with content/base64.'),
3617
- content: z.string().optional().describe('[upload] text content (for CSS/JS). Mutually exclusive with file_path/base64.'),
3618
- base64: z.string().optional().describe('[upload] base64-encoded binary content. Mutually exclusive with file_path/content.'),
3619
- content_type: z.string().optional().describe('[upload] MIME type (auto-detected from extension if omitted).'),
3620
- frame_id: z.string().optional().describe('[upload|list] associate with / filter by a specific frame. Get frame IDs from frame(action="write") or ls.'),
3621
- }, async (args) => {
3622
- try {
3623
- const { action } = args;
3624
- const projectId = getState().projectId;
3625
- if (!projectId) throw new Error('No active project. Call project(action="open") first.');
3626
- if (action === 'upload') {
3627
- const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
3628
- if (!asset_path) throw new Error('asset_path is required for action=upload');
3629
- if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
3630
- let b64, ct;
3631
- if (file_path) {
3632
- const resolved = resolve(file_path);
3633
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
3634
- const buffer = readFileSync(resolved);
3635
- b64 = buffer.toString('base64');
3636
- ct = content_type || mimeFromExt(extname(resolved));
3637
- } else if (content != null) {
3638
- b64 = Buffer.from(content).toString('base64');
3639
- ct = content_type || mimeFromExt(extname(asset_path));
3640
- } else if (rawBase64) {
3641
- b64 = rawBase64;
3642
- ct = content_type || mimeFromExt(extname(asset_path));
3643
- } else {
3644
- throw new Error('Provide file_path, content, or base64');
3645
- }
3646
- const body = { base64: b64, contentType: ct };
3647
- if (frame_id) body.frameId = frame_id;
3648
- return ok(await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body));
3649
- }
3650
- if (action === 'list') {
3651
- const { frame_id } = args;
3652
- const query = frame_id ? `?frameId=${frame_id}` : '';
3653
- return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
3654
- }
3655
- if (action === 'rm') {
3656
- const { asset_path } = args;
3657
- if (!asset_path) throw new Error('asset_path is required for action=rm');
3658
- if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
3659
- return ok(await api('DELETE', `/api/projects/${projectId}/assets/${asset_path}`));
3660
- }
3661
- throw new Error(`Unknown asset action: ${action}`);
3662
- } catch (error) { return err(error); }
3663
- });
3664
-
3665
- // ── Connector tools ───────────────────────────────────────────────
3666
-
3667
- tool('connector', 'Create or remove connectors (arrows) between frames on the surface. `action=connect` adds an arrow from source to target. `action=disconnect` removes one — pass either connectorId directly or source+target to find and delete.', {
3668
- projectId: PROJECT_OVERRIDE_PARAM,
3669
- action: z.enum(['connect', 'disconnect']).describe('Operation to perform.'),
3670
- source: z.string().optional().describe('[connect|disconnect] source frame path or ID'),
3671
- target: z.string().optional().describe('[connect|disconnect] target frame path or ID'),
3672
- label: z.string().optional().describe('[connect] connector label text'),
3673
- type: z.string().optional().describe('[connect] connector type (default: arrow-forward)'),
3674
- color: z.string().optional().describe('[connect] connector color (CSS color string)'),
3675
- connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
3676
- }, async (args) => {
3677
- try {
3678
- const { action } = args;
3679
- if (action === 'connect') {
3680
- const { source, target, label, type = 'arrow-forward', color } = args;
3681
- if (!source || !target) throw new Error('source and target required for action=connect');
3682
- const body = { source, target };
3683
- if (label) body.label = label;
3684
- if (type) body.type = type;
3685
- if (color) body.color = color;
3686
- return ok(await api('POST', '/api/connectors', body));
3687
- }
3688
- if (action === 'disconnect') {
3689
- const { source, target, connectorId } = args;
3690
- if (connectorId) {
3691
- return ok(await api('DELETE', `/api/connectors/${connectorId}`));
3692
- }
3693
- if (source && target) {
3694
- const sourceFrame = await api('GET', `/api/fs/${source.replace(/^\/+/, '')}`);
3695
- const targetFrame = await api('GET', `/api/fs/${target.replace(/^\/+/, '')}`);
3696
- const sourceId = sourceFrame.id;
3697
- const targetId = targetFrame.id;
3698
- if (!sourceId || !targetId) throw new Error('Could not resolve source or target frame');
3699
- const connectors = await api('GET', '/api/connectors');
3700
- const match = (connectors.connectors || connectors || []).find(
3701
- c => c.sourceDesignId === sourceId && c.targetDesignId === targetId
3702
- );
3703
- if (!match) throw new Error(`No connector found from ${source} to ${target}`);
3704
- return ok(await api('DELETE', `/api/connectors/${match.id}`));
3705
- }
3706
- throw new Error('Provide either connectorId, or both source and target');
3707
- }
3708
- throw new Error(`Unknown connector action: ${action}`);
3709
- } catch (error) { return err(error); }
3710
- });
3711
-
3712
- // ── Layout tools ──────────────────────────────────────────────────
3713
-
3714
- tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
3715
- projectId: PROJECT_OVERRIDE_PARAM,
3716
- direction: z.enum(['TB', 'LR', 'BT', 'RL']).optional().default('TB').describe('Layout direction: TB (top-bottom), LR (left-right), BT (bottom-top), RL (right-left)'),
3717
- }, async ({ direction }) => {
3718
- try {
3719
- const body = { direction };
3720
- const result = await api('POST', '/api/layout', body);
3721
- return ok(result);
3722
- } catch (error) { return err(error); }
3723
- });
3724
-
3725
- // ── Skill library tool ───────────────────────────────────────────
3726
-
3727
- // Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
3728
- // dirs + any .skillignore as a convenience so we don't ship a node_modules tree
3729
- // over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
3730
- // CLI's collectSkillTree.
3731
- function collectSkillTreeForPush(dir) {
3732
- const root = resolve(dir);
3733
- if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
3734
- const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
3735
- const NUL = String.fromCharCode(0);
3736
- let ignore = () => false;
3737
- const ign = join(root, '.skillignore');
3738
- if (existsSync(ign)) {
3739
- const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
3740
- ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
3741
- }
3742
- const out = [];
3743
- const walk = (abs, rel) => {
3744
- for (const name of readdirSync(abs)) {
3745
- const childAbs = join(abs, name);
3746
- const childRel = rel ? `${rel}/${name}` : name;
3747
- const st = statSync(childAbs);
3748
- if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
3749
- else if (st.isFile()) {
3750
- if (ignore(childRel)) continue;
3751
- const content = readFileSync(childAbs, 'utf8');
3752
- if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
3753
- out.push({ path: childRel, content });
3754
- }
3755
- }
3756
- };
3757
- walk(root, '');
3758
- return out;
3759
- }
3760
-
3761
- // Ensure a pushed source tree's .gitignore excludes the rebuildable bundle dir, so
3762
- // a skill's machine-specific build output (built into .skillinstall/ by its `setup:`
3763
- // recipe) can never be committed. The server also strips .skillinstall/ from the
3764
- // bundle (skill-ingest DENY_DIRS); this keeps the author's git clean. Idempotent.
3765
- function ensureSkillInstallIgnored(dir) {
3766
- try {
3767
- const gi = join(dir, '.gitignore');
3768
- const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
3769
- if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
3770
- const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
3771
- writeFileSync(gi, body + '.skillinstall/\n');
3772
- return true;
3773
- } catch { return false; }
3774
- }
3775
-
3776
- tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory; export/import to exchange the library as an OKF v0.1 bundle (skills/<slug>/SKILL.md layout).', {
3777
- action: z.enum([
3778
- 'search', 'load', 'list', 'history',
3779
- 'add', 'update', 'remove',
3780
- 'fork', 'push',
3781
- 'attach', 'detach',
3782
- 'favorite', 'unfavorite',
3783
- 'read_file', 'update_file',
3784
- 'export', 'import',
3785
- ]).describe('Operation to perform. export: the skill library as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest the SKILL.md-shaped concepts of an OKF bundle as org skills (files[] or local dir; dryRun supported).'),
3786
- query: z.string().optional().describe('[search] term to match against name/description/content'),
3787
- tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
3788
- scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
3789
- limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100); [export] files per page for format="files" (default 100, max 500)'),
3790
- compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget; [export] with format="files": file paths only (no content)'),
3791
- skill: z.string().optional().describe('[load|history] skill ID (UUID) or slug'),
3792
- version: z.number().optional().describe('[history] fetch this version number\'s full snapshot (content included); omit for the reverse-chron version list'),
3793
- skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
3794
- projectId: z.string().optional().describe('[list] project to list skills for (defaults to active project; falls back to org-attached skills if no project)'),
3795
- name: z.string().optional().describe('[add|update] skill name'),
3796
- description: z.string().optional().describe('[add|update] one-line description'),
3797
- content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
3798
- triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
3799
- path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
3800
- offset: z.number().optional().describe('[search|list|export] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
3801
- maxBytes: z.number().optional().describe('[read_file] return at most this many bytes from offset (default: whole remaining file). Response reports totalSize/offset/truncated/nextOffset.'),
3802
- org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched. NO PROJECT IS NEEDED: for project-less skill work either pass org= here, or bind the session once with get_org(action="use", org="<name>"). A multi-org caller that addresses neither is refused rather than guessed.'),
3803
- setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
3804
- files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps. [import] OKF bundle files inline — skills/<slug>/SKILL.md dirs and Skill/Playbook/SOP/Procedure-typed .md with name + description frontmatter become org skills. Caps: 500 files, 512KB/file, 5MB total.'),
3805
- dir: z.string().optional().describe('[push|export|import] local directory. push: source tree to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces; the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle). export: write the OKF bundle files here (default ./okf-skills-<org>). import: read the bundle from here (alternative to files[]).'),
3806
- deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
3807
- format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
3808
- includeGlobal: z.boolean().optional().describe('[export] include global built-in skills in the bundle (default: org skills only)'),
3809
- dryRun: z.boolean().optional().describe('[import] report {created, updated, skips, warnings} without writing'),
3810
- }, async (args) => {
3811
- try {
3812
- const { action } = args;
3813
- // Org-creating/editing skill actions on the project-less surface must have a
3814
- // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
3815
- // UUID-first exception: update/remove addressed by a skillId derive their org
3816
- // from the skill row server-side, so the bound-org gate is unnecessary.
3817
- // `fork` (and the fork-on-403 branch inside `update`) run the same guard at
3818
- // fork time, inside their cases — a fork is a create, governed by the one rule.
3819
- if (['add', 'update', 'remove', 'push', 'import'].includes(action)) {
3820
- const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
3821
- if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
3822
- }
3823
- switch (action) {
3824
- case 'search': {
3825
- const { query, tags, scope = 'all', limit, offset, compact, org } = args;
3826
- const params = new URLSearchParams();
3827
- if (query) params.set('q', query);
3828
- if (tags?.length) params.set('tags', tags.join(','));
3829
- if (scope) params.set('scope', scope);
3830
- const qs = params.toString();
3831
- const endpoint = query ? '/api/skills/search' : '/api/skills';
3832
- const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`, undefined, org ? { 'X-Drafted-Org': org } : {});
3833
- markSearched(getSessionState().gates, 'skill');
3834
- return ok(shapeSkillCatalog(result, { limit, offset, compact }));
3835
- }
3836
- case 'load': {
3837
- const { skill } = args;
3838
- if (!skill) throw new Error('skill (ID or slug) is required for action=load');
3839
- const isUuid = /^[a-f0-9-]{36}$/.test(skill);
3840
- const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
3841
- const result = await api('GET', endpoint, undefined, args.org ? { 'X-Drafted-Org': args.org } : {});
3842
- if (result?.id) getSessionState().loadedSkillIds.add(result.id);
3843
- return ok(result);
3844
- }
3845
- case 'history': {
3846
- // Skill version git-log. Omit `version` for the list; pass it for a
3847
- // single version's full snapshot (content for view/diff).
3848
- const { skillId, skill: slugArg, version, org } = args;
3849
- const extra = org ? { 'X-Drafted-Org': org } : {};
3850
- let id = skillId;
3851
- if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
3852
- if (!id) throw new Error('skillId or skill (slug) required for action=history');
3853
- if (version != null) return ok(await api('GET', `/api/skills/${id}/versions/${version}`, undefined, extra));
3854
- return ok(await api('GET', `/api/skills/${id}/versions`, undefined, extra));
3855
- }
3856
- case 'list': {
3857
- if (args.scope || args.tags?.length || args.org) {
3858
- const params = new URLSearchParams();
3859
- params.set('scope', args.scope || (args.org ? 'org' : 'all'));
3860
- if (args.tags?.length) params.set('tags', args.tags.join(','));
3861
- const result = await api('GET', `/api/skills?${params.toString()}`, undefined, args.org ? { 'X-Drafted-Org': args.org } : {});
3862
- return ok(shapeSkillCatalog(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
3863
- }
3864
-
3865
- // Prefer the explicit projectId param; otherwise the active project;
3866
- // otherwise fall back to org-attached skills so list works in
3867
- // empty-org / wiki-only sessions where there's no project to bind to.
3868
- const explicit = args.projectId;
3869
- const active = getState().projectId;
3870
- const pid = explicit || active;
3871
- if (pid) {
3872
- try {
3873
- return ok(await api('GET', `/api/projects/${pid}/skills`));
3874
- } catch (e) {
3875
- // 404 / "not found" means the project isn't visible in the
3876
- // session's org (typical after binding to a project in another org). Fall through to
3877
- // org-attached skills rather than bubbling a useless error.
3878
- if (!/not found/i.test(e.message)) throw e;
3879
- }
3880
- }
3881
- const orgId = await getCurrentOrgId();
3882
- if (!orgId) throw new Error('No org context. Open a project (project(action="open")), pass projectId, or pass org=.');
3883
- return ok(await api('GET', `/api/orgs/${orgId}/skills`));
3884
- }
3885
- case 'add': {
3886
- const g2 = g2Block(getSessionState().gates);
3887
- if (g2) return err(new Error(g2));
3888
- const { name, description, content, tags, triggerPatterns, setup, org } = args;
3889
- if (!name || !description || !content) throw new Error('name, description, content required for action=add');
3890
- const body = { name, description, content };
3891
- if (tags) body.tags = tags;
3892
- if (triggerPatterns) body.triggerPatterns = triggerPatterns;
3893
- if (setup !== undefined) body.setup = setup;
3894
- // org names where the skill is born (UUID-first model: org is explicit at
3895
- // create) — without this the bound-org guard accepts org= but the write
3896
- // would land in the session org anyway.
3897
- const addedSkill = await api('POST', '/api/skills', body, org ? { 'X-Drafted-Org': org } : {});
3898
- return ok({ ...addedSkill, ...(await orgEcho(addedSkill, org)) });
3899
- }
3900
- case 'update': {
3901
- // Auto-fork-on-update (tool behavior): if the target is read-only (global/
3902
- // system or another org), the server returns 403 {code:"skill_read_only"};
3903
- // we fork into the caller's org and write the copy, returning forked:true.
3904
- // The raw PUT stays strict — this composition lives in the tool, mirroring
3905
- // the CLI verbatim so consumers treat CLI and MCP identically.
3906
- const { skillId, skill: slugArg, name, description, content, tags, triggerPatterns, setup, org } = args;
3907
- const extra = org ? { 'X-Drafted-Org': org } : {};
3908
- let id = skillId;
3909
- let slug = slugArg;
3910
- if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; slug = s.slug; }
3911
- if (!id) throw new Error('skillId or skill (slug) required for action=update');
3912
- const body = {};
3913
- if (name !== undefined) body.name = name;
3914
- if (description !== undefined) body.description = description;
3915
- if (content !== undefined) body.content = content;
3916
- if (tags !== undefined) body.tags = tags;
3917
- if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
3918
- if (setup !== undefined) body.setup = setup;
3919
- if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
3920
- try {
3921
- const r = await api('PUT', `/api/skills/${id}`, body, extra);
3922
- return ok({ ...r, forked: false, ...(await orgEcho(r, org)) });
3923
- } catch (e) {
3924
- if (e.code !== 'skill_read_only') throw e;
3925
- // Read-only skill → the update becomes a FORK (a create). Its org must be a
3926
- // real root: explicit org=, the active project, or a single-org user's only
3927
- // org — else, multi-org with nothing bound, error rather than guess. The
3928
- // receipt names the resulting org so a surprising fork is visible (Reading A).
3929
- await requireBoundOrgForProjectlessMutation(org);
3930
- let forkId;
3931
- try {
3932
- forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
3933
- } catch (fe) {
3934
- if (fe.status !== 409) throw fe;
3935
- const ownSlug = (fe.body && fe.body.slug) || (e.body && e.body.slug) || slug;
3936
- forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
3937
- }
3938
- const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
3939
- return ok({ ...r2, forked: true, ...(await orgEcho(r2, org)) });
3940
- }
3941
- }
3942
- case 'remove': {
3943
- const { skillId } = args;
3944
- if (!skillId) throw new Error('skillId required for action=remove');
3945
- return ok(await api('DELETE', `/api/skills/${skillId}`));
3946
- }
3947
- case 'fork': {
3948
- // Block-then-fork: copy a readable (global/other-org) skill into the
3949
- // caller's org so it can be edited. Mirrors `drafted skill fork` verbatim.
3950
- const { skillId, skill: slugArg, org } = args;
3951
- const extra = org ? { 'X-Drafted-Org': org } : {};
3952
- let id = skillId;
3953
- if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
3954
- if (!id) throw new Error('skillId or skill (slug) required for action=fork');
3955
- // Forking creates a new skill — same org rule as any create (explicit org=,
3956
- // active project, or single-org; else refuse to guess). Receipt names the org.
3957
- await requireBoundOrgForProjectlessMutation(org);
3958
- try {
3959
- const forkedSkill = await api('POST', `/api/skills/${id}/fork`, {}, extra);
3960
- return ok({ ...forkedSkill, ...(await orgEcho(forkedSkill, org)) });
3961
- } catch (e) {
3962
- if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
3963
- throw e;
3964
- }
3965
- }
3966
- case 'push': {
3967
- // Source-tree ingest. Pass files:[{path,content}] (the usual MCP shape) or a
3968
- // local dir (walked here, server re-enforces the §3 ingest guards). Mirrors
3969
- // `drafted skill push`.
3970
- const { skillId, skill: slugArg, files, dir, deleteMissing, org } = args;
3971
- const extra = org ? { 'X-Drafted-Org': org } : {};
3972
- let id = skillId;
3973
- if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
3974
- if (!id) throw new Error('skillId or skill (slug) required for action=push');
3975
- let fileList = files;
3976
- if (!fileList && dir) fileList = collectSkillTreeForPush(dir);
3977
- if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
3978
- const pushed = await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra);
3979
- if (dir) { try { if (ensureSkillInstallIgnored(dir)) pushed.gitignored = '.skillinstall/'; } catch { /* best-effort */ } }
3980
- return ok({ ...pushed, ...(await orgEcho(pushed, org)) });
3981
- }
3982
- case 'attach': {
3983
- const { skillId } = args;
3984
- if (!skillId) throw new Error('skillId required for action=attach');
3985
- if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
3986
- // Cap (G4): keep attached skills + anchors within the per-project budget so
3987
- // the auto-inject set always fits — reject the attach if it would overflow.
3988
- try {
3989
- const used = await getProjectPrimingSize(getState().projectId);
3990
- let addSize = 0;
3991
- try { const full = await api('GET', `/api/skills/${skillId}`); addSize = (full?.content || '').length; } catch { /* unknown size */ }
3992
- if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this skill')));
3993
- } catch { /* size check is best-effort */ }
3994
- const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
3995
- return ok(result);
3996
- }
3997
- case 'detach': {
3998
- const { skillId } = args;
3999
- if (!skillId) throw new Error('skillId required for action=detach');
4000
- if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
4001
- const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
4002
- return ok(result);
4003
- }
4004
- case 'favorite': {
4005
- const { skillId } = args;
4006
- if (!skillId) throw new Error('skillId required for action=favorite');
4007
- return ok(await api('POST', `/api/skills/favorites/${skillId}`));
4008
- }
4009
- case 'unfavorite': {
4010
- const { skillId } = args;
4011
- if (!skillId) throw new Error('skillId required for action=unfavorite');
4012
- return ok(await api('DELETE', `/api/skills/favorites/${skillId}`));
4013
- }
4014
- case 'read_file': {
4015
- const { skillId, path, offset, maxBytes } = args;
4016
- if (!skillId || !path) throw new Error('skillId and path required for action=read_file');
4017
- const params = new URLSearchParams();
4018
- if (offset != null) params.set('offset', String(offset));
4019
- if (maxBytes != null) params.set('maxBytes', String(maxBytes));
4020
- const qs = params.toString();
4021
- return ok(await api('GET', `/api/skills/${skillId}/files/${path}${qs ? `?${qs}` : ''}`));
4022
- }
4023
- case 'update_file': {
4024
- const { skillId, path, content } = args;
4025
- if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
4026
- return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
4027
- }
4028
-
4029
- // ── export ──────────────────────────────────────────────────
4030
- // The skill library as an OKF v0.1 bundle (skills/<slug>/SKILL.md +
4031
- // supporting files + synthesized log.md/index.md). Mirrors wiki export:
4032
- // format="files" pages the bundle inline; otherwise stdio writes a local
4033
- // dir, remote returns the authenticated tar.gz download URL.
4034
- case 'export': {
4035
- const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
4036
- const inc = args.includeGlobal ? '&includeGlobal=1' : '';
4037
- if (args.format === 'files') {
4038
- const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
4039
- if (args.offset) qp.set('offset', String(args.offset));
4040
- if (args.compact) qp.set('compact', 'true');
4041
- return ok(await api('GET', `/api/skills/export?${qp.toString()}${inc}`, undefined, extra));
4042
- }
4043
- if (isRemote) {
4044
- return ok({
4045
- downloadUrl: `${getServerUrl()}/api/skills/export.tar.gz${args.includeGlobal ? '?includeGlobal=1' : ''}`,
4046
- note: 'Open the URL in a signed-in browser to download the OKF v0.1 skill bundle, or call export with format="files" to page the bundle contents inline.',
4047
- });
4048
- }
4049
- // stdio: write every bundle file under a local directory.
4050
- const skOrgCtx = await getCurrentOrgContext();
4051
- const skOrgSlug = String(skOrgCtx?.name || skOrgCtx?.id || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
4052
- const exportDir = resolve(args.dir || `./okf-skills-${skOrgSlug}`);
4053
- let expOffset = 0;
4054
- let written = 0;
4055
- for (;;) {
4056
- const batch = await api('GET', `/api/skills/export?limit=200&offset=${expOffset}${inc}`, undefined, extra);
4057
- const batchFiles = batch.files || [];
4058
- for (const f of batchFiles) {
4059
- const dest = resolve(exportDir, f.path);
4060
- if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
4061
- mkdirSync(dirname(dest), { recursive: true });
4062
- writeFileSync(dest, f.content, 'utf8');
4063
- written++;
4064
- }
4065
- expOffset += batchFiles.length;
4066
- if (!batch.truncated || batchFiles.length === 0) break;
4067
- }
4068
- return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 skill bundle written (skills/<slug>/SKILL.md layout; index.md and log.md are synthesized).' });
4069
- }
4070
-
4071
- // ── import ──────────────────────────────────────────────────
4072
- // Ingest the SKILL.md-shaped concepts of an OKF bundle as org skills:
4073
- // inline files[] or (stdio) a local dir walked with the push filters.
4074
- // Use wiki(action="import") for a mixed knowledge bundle — it routes
4075
- // skill concepts here and everything else into the wiki.
4076
- case 'import': {
4077
- const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
4078
- let importFiles = args.files;
4079
- if (!importFiles && args.dir) importFiles = collectSkillTreeForPush(args.dir);
4080
- if (!Array.isArray(importFiles) || importFiles.length === 0) {
4081
- throw new Error('import requires files[] (or dir on stdio) with at least one file');
4082
- }
4083
- return ok(await api('POST', '/api/skills/import', { files: importFiles, dryRun: !!args.dryRun }, extra));
4084
- }
4085
-
4086
- default:
4087
- throw new Error(`Unknown skill action: ${action}`);
4088
- }
4089
- } catch (error) { return err(error); }
4090
- });
4091
-
4092
- // ── Wiki tool (org-scoped, no project needed) ─────────────────────
4093
- // All 11 actions dispatch from one tool. Read-only actions skip the
4094
- // skill gate; mutations require org-level wiki-maintainer skills loaded.
4095
-
4096
- tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry. Reference sources with `cite` (appends a numbered entry to the page\'s `# Citations` section).\n\nThe wiki is an OKF v0.1 bundle (Open Knowledge Format): every page carries frontmatter with a `type` (default "Page"), a one-line `description` is recommended, links may use `/a/b.md` or extensionless `a/b` form, and `index.md` at any level is synthesized — read it for a directory listing, never write it. Exchange whole bundles with `export` (conformant tar.gz / local dir / paged files) and `import` (files[] or local dir, dryRun supported).\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
4097
- action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'cite', 'source-register', 'source-list', 'source-get', 'bulk-write', 'export', 'import']).describe('Operation to perform. export: the whole wiki as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest an OKF bundle (files[] or local dir; dryRun supported).'),
4098
- path: z.string().optional().describe('[ls|read|links|cite] wiki path. For ls: default / (root). For read: required. For links/cite: required unless pageId given. Reading `index.md` (any level) returns the SYNTHESIZED OKF directory listing.'),
4099
- pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
4100
- org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched). NO PROJECT IS NEEDED for wiki work: to write project-less, either pass org= here, or bind the session once with get_org(action="use", org="<name>") and then omit it. Multi-org callers MUST address the org one of those two ways — an unaddressed write is refused rather than guessed (it would land in whichever org the session inherited). [write] the org the page is created in. [search] restrict to this org (default: ALL your orgs). [ls|recent|read|links|log|health|edit|mv|rm|bulk-write] target this org\'s wiki instead of the open project\'s org. Ignored when a pageId is given (the page self-derives its org).'),
4101
- recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
4102
- limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
4103
- offset: z.number().optional().describe('[export] pagination offset for format="files"'),
4104
- compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
4105
- format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
4106
- files: z.array(z.object({
4107
- path: z.string().describe('Bundle-relative file path, e.g. "concepts/frames.md"'),
4108
- content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
4109
- })).optional().describe('[import] OKF bundle files inline. index.md files are skipped (synthesized), bundle-root log.md merges into the wiki log page. Caps: 500 files, 512KB/file, 5MB total.'),
4110
- ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-export-<org>). import: recursively read .md files from here (alternative to files[]).') }),
4111
- query: z.string().optional().describe('[search] term to search in title, path, and content'),
4112
- lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
4113
- message: z.string().optional().describe('[log] message to append to the log page (OKF date-grouped format)'),
4114
- verb: z.string().optional().describe('[log] leading bold verb for the entry: Update (default), Creation, Deprecation, or Initialization.'),
4115
- title: z.string().optional().describe('[write] page title (required for write)'),
4116
- content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
4117
- type: z.string().optional().describe('[write] OKF concept type — free-form string, e.g. "Page", "Reference", "Playbook", "Decision", "Metric". Defaults to "Page" on create; must stay non-empty on update (OKF v0.1 requires a type on every page).'),
4118
- raw: z.boolean().optional().describe('[read] return content bytes-as-stored (skip the synthesized OKF frontmatter block). Do NOT build edit operations from a raw read — hashline anchors for edit must come from a normal (non-raw) read.'),
4119
- url: z.string().optional().describe('[cite] citation URL (required for cite)'),
4120
- label: z.string().optional().describe('[cite] link label for the citation (defaults to the URL)'),
4121
- frontmatter: z.any().optional().describe('[write] frontmatter object. Recommended OKF keys: a one-line "description" (index listings and consumers read it), "tags" (list), "resource" (canonical URI of the underlying asset). Unknown keys are preserved; never set "timestamp" (synthesized from the last update).'),
4122
- operations: z.array(z.object({
4123
- type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
4124
- lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
4125
- newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
4126
- })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
4127
- from: z.string().optional().describe('[mv] source path'),
4128
- to: z.string().optional().describe('[mv] destination path'),
4129
- dryRun: z.boolean().optional().describe('[mv|rm|import] preview impact without applying changes (import: returns the {creates, updates, skips, warnings} report without writing)'),
4130
- file_path: z.string().optional().describe('[source-register] absolute path to a local file. Server hashes it and registers the source. stdio MCP only.'),
4131
- contentHash: z.string().optional().describe('[source-register|source-list|cite] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP). For cite: also registers the cited source.'),
4132
- filename: z.string().optional().describe('[source-register|cite] original filename for the source (informational)'),
4133
- contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
4134
- size: z.number().optional().describe('[source-register] byte size (informational)'),
4135
- sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
4136
- manifest_path: z.string().optional().describe('[bulk-write] absolute path to a JSON file containing {pages: [{path, title, content, type?, frontmatter?}, ...]}. The MCP wrapper reads the file locally and posts the array to the server in a single request — avoids round-tripping every page through tool args.'),
4137
- pages: z.array(z.any()).optional().describe('[bulk-write] alternative to manifest_path: pass the pages array inline.'),
4138
- }, async (args) => {
4139
- try {
4140
- const { action } = args;
4141
-
4142
- // UUID-first: a `pageId` (edit/mv/rm) or an explicit `org` (write) makes the
4143
- // target org unambiguous, so the bound-org gate is unnecessary — skip it.
4144
- // Otherwise (path-addressed, multi-org, nothing bound) the gate still
4145
- // refuses to guess the org so a write never silently lands in the wrong one.
4146
- const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
4147
- if (['write', 'edit', 'mv', 'rm', 'bulk-write', 'cite', 'import'].includes(action)) {
4148
- if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
4149
- }
4150
-
4151
- // Resolve org context for this CALL. An explicit org= override wins (per
4152
- // request, nothing is switched); otherwise the session's binding (the open
4153
- // project's org). Resolving the override here keeps the echoed `org` field
4154
- // and every emitted browser URL truthful about where the call landed.
4155
- // Where the write will ACTUALLY land: the working org (explicit switch, else the
4156
- // bound project's org — the same address api() puts on the wire). getCurrentOrgContext
4157
- // reports the session's INHERITED org, so echoing it made a correctly-placed write
4158
- // look misfiled — and an agent trusting that echo would "fix" a page that was fine.
4159
- const working = workingOrgId();
4160
- // Cached for 30s, so this is a cache hit on all but the first wiki call in a
4161
- // session — cheap enough to always have on hand for the receipt below.
4162
- const orgList = await getOrgList();
4163
- let orgCtx = working
4164
- ? (orgList.find(o => o.id === working) || { id: working, name: null })
4165
- : await getCurrentOrgContext();
4166
- if (args.org) {
4167
- const d = await api('GET', '/api/orgs');
4168
- const list = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
4169
- const wanted = String(args.org).toLowerCase();
4170
- const byName = list.filter(o => (o.name || '').toLowerCase() === wanted);
4171
- const match = list.find(o => o.id === args.org) || (byName.length === 1 ? byName[0] : null);
4172
- if (match) orgCtx = match;
4173
- }
4174
- const orgId = orgCtx?.id || null;
4175
- // Mutation responses include `org` so the agent always sees where the
4176
- // write landed — eliminates silent cross-org confusion.
4177
- //
4178
- // A UUID-addressed resource (pageId=…) SELF-DERIVES its org server-side, so
4179
- // for those the write can land in an org this session isn't bound to. Echoing
4180
- // the session's working org there is the same defect the URL builder had: the
4181
- // receipt would name one org while `url` pointed at another. Prefer the org
4182
- // carried on the returned row, and fall back to the session context only when
4183
- // the response doesn't name one.
4184
- const withOrg = (result, resourceOrgId) => ({
4185
- ...result,
4186
- org: receiptOrg({ resourceOrgId: resourceOrgId || result?.orgId, sessionOrg: orgCtx, orgList }),
4187
- });
4188
- // Org-qualify every browser URL this tool emits (shadows the module fn for
4189
- // all call sites below) so links are portable across the viewer's orgs.
4190
- // A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
4191
- // built from the org on the returned row (`resourceOrg`), not from this session's
4192
- // working org — otherwise `wiki(action="read", pageId=…)` hands back a link into
4193
- // whatever org the session happens to be bound to, for a page that lives elsewhere.
4194
- // Path-addressed calls have no resource org and correctly fall back to `orgId`.
4195
- const wikiBrowserUrl = (p, resourceOrg) => wikiPageUrl(p, resourceOrg || orgId);
4196
-
4197
- // ── Skill gate: all mutation actions ──────────────────────────
4198
- // Ensure the wiki-maintainer skill is attached to this org BEFORE the
4199
- // gate check, so the gate fires reliably on the very first wiki call —
4200
- // not just after the org has visited /wiki in a browser. Idempotent.
4201
- const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'cite', 'source-register', 'bulk-write', 'import']);
4202
- if (MUTATING.has(action)) {
4203
- try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
4204
- const skillErr = await checkOrgSkills(orgId, action);
4205
- if (skillErr) return err(new Error(skillErr));
4206
- }
4207
-
4208
- switch (action) {
4209
-
4210
- // ── ls ──────────────────────────────────────────────────────
4211
- case 'ls': {
4212
- const { path: lsPath = '/', recursive: lsRecursive = false } = args;
4213
- const { pages, pathToPage } = await getTreeAsMap(orgHeader);
4214
- const parent = normalizeWikiPath(lsPath);
4215
-
4216
- if (lsRecursive) {
4217
- // Full tree under path with depth
4218
- const prefix = parent ? parent + '/' : '';
4219
- const filtered = !parent ? pages : pages.filter(p => p.path === parent || p.path.startsWith(prefix));
4220
- const tree = filtered.map(p => ({
4221
- depth: !parent ? p.path.split('/').length - 1 : p.path.split('/').length - parent.split('/').length - (p.path === parent ? 1 : 0),
4222
- path: p.path,
4223
- title: p.title,
4224
- type: p.type,
4225
- id: p.id,
4226
- url: wikiBrowserUrl(p.path),
4227
- }));
4228
- return ok({ tree });
4229
- }
4230
-
4231
- // Non-recursive: children of parent path
4232
- if (!parent) {
4233
- // Root: show top-level directories + root pages
4234
- const dirs = new Set();
4235
- const rootPages = [];
4236
- for (const p of pages) {
4237
- if (!p.path.includes('/')) { rootPages.push(p); }
4238
- else { dirs.add(p.path.split('/')[0]); }
4239
- }
4240
- return ok({
4241
- tree: [
4242
- ...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d, url: wikiBrowserUrl(d) })),
4243
- ...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) })),
4244
- ],
4245
- });
4246
- }
4247
-
4248
- // Has parent: show immediate children
4249
- const children = [];
4250
- const seenDirs = new Set();
4251
- const prefix = parent + '/';
4252
- for (const p of pages) {
4253
- if (p.path === parent) {
4254
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
4255
- } else if (p.path.startsWith(prefix)) {
4256
- const rest = p.path.slice(prefix.length);
4257
- if (rest.includes('/')) {
4258
- const sub = rest.split('/')[0];
4259
- if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub, url: wikiBrowserUrl(parent + '/' + sub) }); }
4260
- } else {
4261
- children.push({ type: 'page', path: p.path, title: p.title, id: p.id, url: wikiBrowserUrl(p.path) });
4262
- }
4263
- }
4264
- }
4265
- return ok({ tree: children });
4266
- }
4267
-
4268
- // ── recent ──────────────────────────────────────────────────
4269
- case 'recent': {
4270
- const { limit: recentLimit = 10 } = args;
4271
- const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
4272
- const recent = (tree.pages || [])
4273
- .filter(p => p.updatedAt)
4274
- .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
4275
- .slice(0, Math.max(1, recentLimit))
4276
- .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt, url: wikiBrowserUrl(p.path) }));
4277
- return ok({ pages: recent });
4278
- }
4279
-
4280
- // ── read ────────────────────────────────────────────────────
4281
- // Returns content in hashline format (`LINE+ID|content`) so the
4282
- // agent can produce hashline edit operations. Mirrors frame.read.
4283
- case 'read': {
4284
- const { path: readPath, pageId: readPageId, lines: readLines, raw: readRaw } = args;
4285
- if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
4286
- let page;
4287
- if (readPageId) {
4288
- // UUID-first: address the page directly, org auto-derives server-side.
4289
- const params = new URLSearchParams({ format: 'hashline' });
4290
- if (readLines) params.set('lines', readLines);
4291
- if (readRaw) params.set('raw', 'true');
4292
- page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
4293
- } else {
4294
- if (!readPath) throw new Error('path or pageId required for action=read');
4295
- const normalized = normalizeWikiPath(readPath);
4296
- const params = new URLSearchParams({ path: normalized, format: 'hashline' });
4297
- if (readLines) params.set('lines', readLines);
4298
- if (readRaw) params.set('raw', 'true');
4299
- page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
4300
- }
4301
- // Get backlink count via search (approximate)
4302
- let backlinkCount = 0;
4303
- try {
4304
- const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(page.path)}`, undefined, orgHeader);
4305
- backlinkCount = (searchRes.hits || []).length;
4306
- } catch { /* best-effort */ }
4307
- return ok({
4308
- id: page.id,
4309
- path: page.path,
4310
- title: page.title,
4311
- type: page.type,
4312
- frontmatter: page.frontmatter,
4313
- content: page.content,
4314
- totalLines: page.totalLines,
4315
- lastEditedBy: page.updatedBy,
4316
- lastEditedAt: page.updatedAt,
4317
- backlinkCount,
4318
- url: wikiBrowserUrl(page.path, page.orgId),
4319
- });
4320
- }
4321
-
4322
- // ── search ──────────────────────────────────────────────────
4323
- case 'search': {
4324
- const { query: searchQuery, limit: searchLimit = 25, org: searchOrg } = args;
4325
- if (!searchQuery) throw new Error('query required for action=search');
4326
- // Default: search ALL the caller's orgs (discovery needs no bound org).
4327
- // An explicit `org` scopes to that one org.
4328
- const qp = new URLSearchParams({ q: searchQuery, limit: String(searchLimit) });
4329
- if (!searchOrg) qp.set('scope', 'all');
4330
- const result = await api('GET', `/api/wiki/search?${qp.toString()}`, undefined, orgHeader);
4331
- // Surface pageId (re-address by UUID, org-free) + the hit's own org, and
4332
- // build the URL from the hit's org so cross-org links are correct.
4333
- const hits = (result.hits || []).map(h => ({
4334
- id: h.id,
4335
- path: h.path,
4336
- title: h.title,
4337
- org: h.orgName || h.orgSlug || undefined,
4338
- orgId: h.orgId || undefined,
4339
- url: wikiPageUrl(h.path, h.orgId || orgId),
4340
- }));
4341
- markSearched(getSessionState().gates, 'wiki');
4342
- return ok({ hits });
4343
- }
4344
-
4345
- // ── links ───────────────────────────────────────────────────
4346
- case 'links': {
4347
- const { path: linksPath, pageId: linksPageId } = args;
4348
- let id = linksPageId;
4349
- if (!id) {
4350
- if (!linksPath) throw new Error('path or pageId required for action=links');
4351
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(linksPath))}`, undefined, orgHeader);
4352
- id = page.id;
4353
- }
4354
- return ok(await api('GET', `/api/wiki/pages/${id}/links`));
4355
- }
4356
-
4357
- // ── log ─────────────────────────────────────────────────────
4358
- // OKF log.md format: newest-first `## YYYY-MM-DD` date headings (UTC),
4359
- // each with `* **Verb**: message (agent, HH:MM UTC)` bullets. Legacy
4360
- // `## <ISO datetime> ...` headings on existing log pages are left as-is.
4361
- case 'log': {
4362
- const { message: logMessage, verb: logVerb } = args;
4363
- if (!logMessage) throw new Error('message required for action=log');
4364
- const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
4365
- const now = new Date();
4366
- const entry = formatOkfLogEntry(logVerb, logMessage, agentName, now);
4367
- const logTitle = (orgCtx?.name ? orgCtx.name + ' ' : '') + 'Log';
4368
-
4369
- // Try to read existing log page (raw: bytes-as-stored, no synthesis)
4370
- let logPage = null;
4371
- try {
4372
- logPage = await api('GET', '/api/wiki/page?path=log&raw=true', undefined, orgHeader);
4373
- } catch {
4374
- // Create new log page
4375
- const created = await api('POST', '/api/wiki/pages', {
4376
- path: 'log',
4377
- title: 'Log',
4378
- type: 'Log',
4379
- content: appendOkfLogEntry('', entry, now, logTitle),
4380
- }, orgHeader);
4381
- return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
4382
- }
4383
-
4384
- // Append under today's date heading (created at the top if missing)
4385
- const updatedContent = appendOkfLogEntry(logPage.content || '', entry, now, logTitle);
4386
- await api('PATCH', `/api/wiki/pages/${logPage.id}`, { content: updatedContent }, orgHeader);
4387
- return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
4388
- }
4389
-
4390
- // ── cite ────────────────────────────────────────────────────
4391
- // Append a numbered citation to a page's `# Citations` section
4392
- // (creating the section if missing), OKF style: `[n] [label](url)`.
4393
- // Optionally registers a wiki source when contentHash is given.
4394
- case 'cite': {
4395
- const { path: citePath, pageId: citePageId, url: citeUrl, label: citeLabel, contentHash: citeHash, filename: citeFilename } = args;
4396
- if (!citeUrl) throw new Error('url required for action=cite');
4397
- let page;
4398
- if (citePageId) {
4399
- page = await api('GET', `/api/wiki/pages/${citePageId}?raw=true`);
4400
- } else {
4401
- if (!citePath) throw new Error('path or pageId required for action=cite');
4402
- page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(citePath))}&raw=true`, undefined, orgHeader);
4403
- }
4404
- const lines = (page.content || '').split('\n');
4405
- let maxN = 0;
4406
- for (const l of lines) {
4407
- const m = l.match(/^\[(\d+)\]\s/);
4408
- if (m) maxN = Math.max(maxN, parseInt(m[1], 10));
4409
- }
4410
- const n = maxN + 1;
4411
- const entry = `[${n}] [${citeLabel || citeUrl}](${citeUrl})`;
4412
- let content;
4413
- const hIdx = lines.findIndex((l) => /^#{1,3}\s+Citations\s*$/.test(l));
4414
- if (hIdx < 0) {
4415
- const base = (page.content || '').replace(/\s+$/, '');
4416
- content = (base ? base + '\n\n' : '') + '# Citations\n\n' + entry + '\n';
4417
- } else {
4418
- let end = hIdx + 1;
4419
- while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
4420
- let insertAt = end;
4421
- while (insertAt > hIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
4422
- lines.splice(insertAt, 0, entry);
4423
- content = lines.join('\n');
4424
- }
4425
- await api('PATCH', `/api/wiki/pages/${page.id}`, { content });
4426
- let source = null;
4427
- if (citeHash) {
4428
- try {
4429
- source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
4430
- } catch { /* source registration is best-effort */ }
4431
- }
4432
- return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path, page.orgId) }));
4433
- }
4434
-
4435
- // ── health ──────────────────────────────────────────────────
4436
- case 'health': {
4437
- return ok(await api('GET', '/api/wiki/health', undefined, orgHeader));
4438
- }
4439
-
4440
- // ── export ──────────────────────────────────────────────────
4441
- // The whole wiki as an OKF v0.1 bundle. format="files" pages the bundle
4442
- // inline; otherwise stdio writes a local dir, remote returns the
4443
- // authenticated tar.gz download URL.
4444
- case 'export': {
4445
- if (args.format === 'files') {
4446
- const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
4447
- if (args.offset) qp.set('offset', String(args.offset));
4448
- if (args.compact) qp.set('compact', 'true');
4449
- return ok(withOrg(await api('GET', `/api/wiki/export?${qp.toString()}`, undefined, orgHeader)));
4450
- }
4451
- if (isRemote) {
4452
- return ok(withOrg({
4453
- downloadUrl: `${getServerUrl()}/api/wiki/export.tar.gz`,
4454
- note: 'Open the URL in a signed-in browser to download the OKF v0.1 bundle, or call export with format="files" to page the bundle contents inline.',
4455
- }));
4456
- }
4457
- // stdio: write every bundle file under a local directory.
4458
- const orgSlug = String(orgCtx?.name || orgId || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
4459
- const exportDir = resolve(args.dir || `./okf-export-${orgSlug}`);
4460
- let expOffset = 0;
4461
- let written = 0;
4462
- for (;;) {
4463
- const batch = await api('GET', `/api/wiki/export?limit=200&offset=${expOffset}`, undefined, orgHeader);
4464
- const batchFiles = batch.files || [];
4465
- for (const f of batchFiles) {
4466
- const dest = resolve(exportDir, f.path);
4467
- if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
4468
- mkdirSync(dirname(dest), { recursive: true });
4469
- writeFileSync(dest, f.content, 'utf8');
4470
- written++;
4471
- }
4472
- expOffset += batchFiles.length;
4473
- if (!batch.truncated || batchFiles.length === 0) break;
4474
- }
4475
- return ok(withOrg({ exported: written, dir: exportDir, note: 'OKF v0.1 bundle written. Pages carry synthesized frontmatter; index.md files are synthesized directory listings.' }));
4476
- }
4477
-
4478
- // ── import ──────────────────────────────────────────────────
4479
- // Ingest an OKF bundle: inline files[] or (stdio) a local dir walked
4480
- // for .md files. index.md skipped, bundle-root log.md merged into the
4481
- // wiki log page, frontmatter lifted with unknown keys preserved.
4482
- case 'import': {
4483
- let importFiles = args.files;
4484
- if (!importFiles && args.dir) {
4485
- const root = resolve(args.dir);
4486
- if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
4487
- importFiles = [];
4488
- const walk = (d) => {
4489
- for (const ent of readdirSync(d, { withFileTypes: true })) {
4490
- if (ent.name.startsWith('.')) continue;
4491
- const p = join(d, ent.name);
4492
- if (ent.isDirectory()) walk(p);
4493
- else if (/\.md$/i.test(ent.name)) {
4494
- if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
4495
- importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
4496
- }
4497
- }
4498
- };
4499
- walk(root);
4500
- }
4501
- if (!Array.isArray(importFiles) || importFiles.length === 0) {
4502
- throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
4503
- }
4504
- const result = await api('POST', '/api/wiki/import', { files: importFiles, dryRun: !!args.dryRun }, orgHeader);
4505
- return ok(withOrg(result));
4506
- }
4507
-
4508
- // ── write ───────────────────────────────────────────────────
4509
- case 'write': {
4510
- const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
4511
- if (!writePath) throw new Error('path required for action=write');
4512
- if (!writeTitle) throw new Error('title required for action=write');
4513
- const normalized = normalizeWikiPath(writePath);
4514
- const body = { path: normalized, title: writeTitle };
4515
- if (writeContent !== undefined) body.content = writeContent;
4516
- if (writeType) body.type = writeType;
4517
- if (frontmatter !== undefined) body.frontmatter = frontmatter;
4518
-
4519
- // Writing the reserved log page directly is allowed (it IS an editable
4520
- // page) but the `log` action keeps the OKF date-grouped format for you.
4521
- const logNote = normalized === 'log'
4522
- ? 'Note: prefer wiki(action="log") for log entries — it maintains the OKF date-grouped format (## YYYY-MM-DD headings, newest first).'
4523
- : undefined;
4524
-
4525
- // Check if page exists — if so, update; otherwise create. `orgHeader`
4526
- // (the `org` arg) targets a specific org without switching the active org.
4527
- try {
4528
- const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
4529
- const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
4530
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
4531
- } catch {
4532
- const result = await api('POST', '/api/wiki/pages', body, orgHeader);
4533
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
4534
- }
4535
- }
4536
-
4537
- // ── edit ────────────────────────────────────────────────────
4538
- // Hashlines come from `read` (which now formats content as
4539
- // `LINE+ID|content`). The server applies the ops via the same
4540
- // hashline algorithm — no client-side re-hashing, no algorithm drift.
4541
- case 'edit': {
4542
- const { path: editPath, pageId: editPageId, operations: editOps } = args;
4543
- if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
4544
- let id = editPageId;
4545
- if (!id) {
4546
- if (!editPath) throw new Error('path or pageId required for action=edit');
4547
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(editPath))}`, undefined, orgHeader);
4548
- id = page.id;
4549
- }
4550
- const result = await api('POST', `/api/wiki/pages/${id}/edit`, { operations: editOps });
4551
- return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path, result.orgId) }));
4552
- }
4553
-
4554
- // ── mv ──────────────────────────────────────────────────────
4555
- case 'mv': {
4556
- const { from: mvFrom, to: mvTo, pageId: mvPageId, dryRun: mvDryRun = false } = args;
4557
- if (!mvTo) throw new Error('to (destination path) required for action=mv');
4558
- const toPath = normalizeWikiPath(mvTo);
4559
- let id = mvPageId;
4560
- if (!id) {
4561
- if (!mvFrom) throw new Error('from (source path) or pageId required for action=mv');
4562
- const fromPath = normalizeWikiPath(mvFrom);
4563
- if (fromPath === toPath) throw new Error('source and destination are the same');
4564
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`, undefined, orgHeader);
4565
- id = page.id;
4566
- }
4567
-
4568
- if (mvDryRun) {
4569
- const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
4570
- return ok(withOrg({ impacted: referrers.map(r => ({ path: r.path, title: r.title })) }));
4571
- }
4572
-
4573
- // Server-side cascade: /move rewrites referrers in one transaction
4574
- const moved = await api('PATCH', `/api/wiki/pages/${id}/move`, { path: toPath });
4575
- return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path, moved.orgId) }));
4576
- }
4577
-
4578
- // ── rm ──────────────────────────────────────────────────────
4579
- case 'rm': {
4580
- const { path: rmPath, pageId: rmPageId, dryRun: rmDryRun = false } = args;
4581
- let id = rmPageId;
4582
- let normalized = null;
4583
- if (!id) {
4584
- if (!rmPath) throw new Error('path or pageId required for action=rm');
4585
- normalized = normalizeWikiPath(rmPath);
4586
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
4587
- id = page.id;
4588
- }
4589
- const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
4590
- const broken = referrers.map(r => ({ path: r.path, title: r.title }));
4591
-
4592
- if (rmDryRun) {
4593
- return ok(withOrg({ brokenAfterDelete: broken }));
4594
- }
4595
- await api('DELETE', `/api/wiki/pages/${id}`);
4596
- return ok(withOrg({ deleted: true, path: normalized, id, brokenReferences: broken }));
4597
- }
4598
-
4599
- // ── bulk-write ──────────────────────────────────────────────
4600
- // Commit many pages in a single transaction. The MCP wrapper reads
4601
- // the manifest file locally and posts the pages array inline to
4602
- // /api/wiki/pages/bulk — avoids paying a tool-call round-trip per
4603
- // page and keeps the orchestrator's context clean.
4604
- case 'bulk-write': {
4605
- const { manifest_path, pages: inlinePages } = args;
4606
- let pages;
4607
- if (manifest_path) {
4608
- if (!existsSync(manifest_path)) throw new Error(`manifest not found: ${manifest_path}`);
4609
- const text = readFileSync(manifest_path, 'utf8');
4610
- let parsed;
4611
- try { parsed = JSON.parse(text); } catch (e) { throw new Error(`invalid JSON in ${manifest_path}: ${e.message}`); }
4612
- // Accept either {pages: [...]} or a bare array
4613
- pages = Array.isArray(parsed) ? parsed : parsed.pages;
4614
- } else if (Array.isArray(inlinePages)) {
4615
- pages = inlinePages;
4616
- } else {
4617
- throw new Error('bulk-write requires either manifest_path or pages array');
4618
- }
4619
- if (!Array.isArray(pages) || pages.length === 0) throw new Error('pages array is empty');
4620
- // Strip surplus fields the server ignores; keep payload tight.
4621
- const payload = pages.map((p) => ({
4622
- path: p.path,
4623
- title: p.title,
4624
- content: p.content,
4625
- type: p.type,
4626
- frontmatter: p.frontmatter,
4627
- }));
4628
- const result = await api('POST', '/api/wiki/pages/bulk', { pages: payload }, orgHeader);
4629
- return ok(withOrg({
4630
- created: result.created?.length ?? 0,
4631
- updated: result.updated?.length ?? 0,
4632
- createdPages: (result.created ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
4633
- updatedPages: (result.updated ?? []).map((r) => ({ path: r.path, url: wikiBrowserUrl(r.path) })),
4634
- errors: result.errors ?? [],
4635
- }));
4636
- }
4637
-
4638
- // ── source-register ────────────────────────────────────────
4639
- // Register an external source (paper, article, transcript) by content
4640
- // hash. Idempotent: same hash + same org returns the existing source
4641
- // with `isNew: false` and the pages already derived from it. This is
4642
- // the dedup boundary — if isNew=false and derivedPages is non-empty,
4643
- // the agent should extend those pages, not create new ones.
4644
- case 'source-register': {
4645
- const { file_path: srcFile, contentHash, filename, contentType, size } = args;
4646
- if (!srcFile && !contentHash) throw new Error('file_path or contentHash required for action=source-register');
4647
- const body = {};
4648
- if (contentHash) body.contentHash = contentHash;
4649
- if (filename) body.filename = filename;
4650
- if (contentType) body.contentType = contentType;
4651
- if (size !== undefined) body.size = size;
4652
- if (srcFile && !contentHash) {
4653
- // Hash locally — server may not be able to read this client's filesystem.
4654
- if (!existsSync(srcFile)) throw new Error(`file not found: ${srcFile}`);
4655
- const buf = readFileSync(srcFile);
4656
- body.contentHash = createHash('sha256').update(buf).digest('hex');
4657
- body.size = body.size ?? buf.byteLength;
4658
- body.filename = body.filename ?? basename(srcFile);
4659
- }
4660
- const result = await api('POST', '/api/wiki/sources', body);
4661
- return ok(withOrg(result));
4662
- }
4663
-
4664
- // ── source-list ────────────────────────────────────────────
4665
- case 'source-list': {
4666
- const { contentHash, limit: srcLimit = 50 } = args;
4667
- if (contentHash) {
4668
- try {
4669
- const result = await api('GET', `/api/wiki/sources?hash=${encodeURIComponent(contentHash)}`);
4670
- return ok({ sources: [result] });
4671
- } catch (e) {
4672
- if (/Not found/.test(e.message)) return ok({ sources: [] });
4673
- throw e;
4674
- }
4675
- }
4676
- const result = await api('GET', `/api/wiki/sources?limit=${srcLimit}`);
4677
- return ok(result);
4678
- }
4679
-
4680
- // ── source-get ─────────────────────────────────────────────
4681
- case 'source-get': {
4682
- const { sourceId } = args;
4683
- if (!sourceId) throw new Error('sourceId required for action=source-get');
4684
- const result = await api('GET', `/api/wiki/sources/${encodeURIComponent(sourceId)}`);
4685
- return ok(result);
4686
- }
4687
-
4688
- default:
4689
- throw new Error(`Unknown wiki action: ${action}`);
4690
- }
4691
- } catch (error) { return err(error); }
4692
- });
4693
-
4694
- // ── Minions ───────────────────────────────────────────────────────
4695
-
4696
- function compactMinionEntry(c) {
4697
- if (!c || typeof c !== 'object') return c;
4698
- return { id: c.id, slug: c.slug, name: c.name, enabled: c.enabled, projectId: c.projectId };
4699
- }
4700
-
4701
- // Shape a {minions:[...]} list with limit/offset pagination + optional compact
4702
- // mode, mirroring shapeSkillCatalog so large lists stay within token budget.
4703
- function shapeMinionList(result, { limit, offset = 0, compact = false } = {}) {
4704
- if (!Array.isArray(result?.minions)) return result;
4705
- const total = result.minions.length;
4706
- const start = Math.max(0, Math.floor(Number(offset) || 0));
4707
- const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
4708
- const page = result.minions.slice(start, start + cap);
4709
- result.totalAvailable = total;
4710
- result.offset = start;
4711
- result.returned = page.length;
4712
- result.truncated = start + page.length < total;
4713
- result.minions = compact ? page.map(compactMinionEntry) : page;
4714
- result.note = compact
4715
- ? 'Compact list: {id,slug,name,enabled,projectId} only. Use minion(action="get", id="<id>") for full config; limit/offset to page.'
4716
- : 'Minions are scoped to the active project (all org Minions when no project is open). Use limit/offset to page; compact=true for a leaner list.';
4717
- return result;
4718
- }
4719
-
4720
- // Friendlier message when the agent allowlist gate (requireAgentAccess) rejects.
4721
- function minionGateError(e) {
4722
- if (e?.status === 403 || e?.code === 'agent_disabled') {
4723
- return new Error('Minion management is not enabled for this org/account (agent allowlist). Ask an admin to add your org or email to DRAFTED_AGENT_ALLOWED_ORGS / DRAFTED_AGENT_ALLOWED_EMAILS.');
4724
- }
4725
- return e;
4726
- }
4727
2415
 
4728
2416
  tool('minion', {
4729
2417
  action: z.enum(['meta', 'list', 'get', 'create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve']).describe('Operation to perform. test_* drive a QA conversation against a Minion you own (even disabled) to verify it end-to-end.'),
@@ -4732,7 +2420,7 @@ tool('minion', {
4732
2420
  fresh: z.boolean().optional().describe('[test_start] start a brand-new run instead of resuming your latest'),
4733
2421
  actionId: z.string().optional().describe('[test_resolve] id of the pending action to resolve'),
4734
2422
  approve: z.boolean().optional().describe('[test_resolve] approve (default true) or reject the pending action'),
4735
- projectId: z.string().optional().describe('[create|meta] project to bind/scope to (defaults to the active project). The org derives from this project open the target project first via project(action="open") if none is active.'),
2423
+ projectId: z.string().optional().describe('[meta|list|create] the project this Minion belongs to REQUIRED, as a path (/projects/<name> or /projects/<folder>/<name>), slug, or UUID. Minions are project-scoped: they write producibles into that project\'s surface. No session fallback name the project.'),
4736
2424
  name: z.string().optional().describe('[create|update] Minion name'),
4737
2425
  description: z.string().optional().describe('[create|update] one-line description shown to the consumer'),
4738
2426
  enabled: z.boolean().optional().describe('[create|update] whether the Minion is live; a disabled Minion 404s on its /c/<slug> link. enable/disable set this directly.'),
@@ -4768,20 +2456,21 @@ tool('minion', {
4768
2456
  }, async (args) => {
4769
2457
  try {
4770
2458
  const { action } = args;
2459
+ // Minions are project-scoped: meta/list/create need the project, addressed
2460
+ // by fs path (or slug/UUID). Resolved once, scoped via withProjectOverride so
2461
+ // api() appends THIS project (not whatever session is bound), echoed back as projectPath.
2462
+ const projectNeeded = action === 'meta' || action === 'list' || action === 'create';
2463
+ const project = projectNeeded ? await resolveProjectArg(args.projectId) : null;
2464
+ const projectPath = project ? `/projects/${project.slug}` : null;
2465
+ const scoped = (fn) => project ? withProjectOverride(project, fn) : fn();
4771
2466
  switch (action) {
4772
2467
  case 'meta': {
4773
- const active = getState().projectId;
4774
- const pid = active || args.projectId;
4775
- if (!pid) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
4776
- // api() auto-appends the active project; add projectId explicitly only when none is active.
4777
- const path = active ? '/api/minions/meta' : `/api/minions/meta?projectId=${encodeURIComponent(pid)}`;
4778
- return ok(await api('GET', path));
2468
+ return ok(await scoped(() => api('GET', '/api/minions/meta').then(r => ({ ...r, projectPath }))));
4779
2469
  }
4780
2470
  case 'list': {
4781
- // api() auto-appends the active project as ?projectId — so this lists the
4782
- // active project's Minions, or all org Minions when none is open.
4783
- const result = await api('GET', '/api/minions');
4784
- return ok(shapeMinionList(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
2471
+ const result = await scoped(() => api('GET', '/api/minions'));
2472
+ const minions = (result.minions || []).map(m => ({ ...m, projectPath }));
2473
+ return ok({ ...result, minions, projectPath });
4785
2474
  }
4786
2475
  case 'get': {
4787
2476
  if (!args.id) throw new Error('id is required for action=get');
@@ -4789,16 +2478,15 @@ tool('minion', {
4789
2478
  }
4790
2479
  case 'create': {
4791
2480
  // projectId lives in the BODY (the POST route reads body, ignores the query).
4792
- const projectId = args.projectId || getState().projectId;
4793
- if (!projectId) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
4794
2481
  const { name, description, target, checklist, output, enabled } = args;
4795
2482
  if (!name || !target || !Array.isArray(checklist) || !output) {
4796
2483
  throw new Error('name, target, checklist[], and output are required for action=create');
4797
2484
  }
4798
- const body = { projectId, name, target, checklist, output };
2485
+ const body = { projectId: project.id, name, target, checklist, output };
4799
2486
  if (description !== undefined) body.description = description;
4800
2487
  if (enabled !== undefined) body.enabled = enabled;
4801
- return ok(await api('POST', '/api/minions', body));
2488
+ const created = await scoped(() => api('POST', '/api/minions', body));
2489
+ return ok({ ...created, projectPath });
4802
2490
  }
4803
2491
  case 'update': {
4804
2492
  if (!args.id) throw new Error('id is required for action=update');
@@ -4835,7 +2523,7 @@ tool('minion', {
4835
2523
  default:
4836
2524
  throw new Error(`Unknown minion action: ${action}`);
4837
2525
  }
4838
- } catch (error) { return err(minionGateError(error)); }
2526
+ } catch (error) { return err(error); }
4839
2527
  });
4840
2528
 
4841
2529
  // ── Resource: canvas info ─────────────────────────────────────────
@@ -4851,13 +2539,583 @@ server.resource('info', 'drafted://info', {
4851
2539
  text: JSON.stringify({
4852
2540
  version: PACKAGE_VERSION,
4853
2541
  layers: LAYERS,
4854
- pathFormat: '/{layer}/{lane}/{filename}',
4855
- tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv'],
2542
+ pathFormat: '/o/<org>/<root>/<path> — org first, no org switching',
2543
+ roots: {
2544
+ wiki: '/o/<org>/wiki/<path>',
2545
+ skills: '/o/<org>/skills/<slug>',
2546
+ projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
2547
+ },
2548
+ tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'screenshot', 'minion'],
4856
2549
  }, null, 2),
4857
2550
  }],
4858
2551
  };
4859
2552
  });
4860
2553
 
2554
+ 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` (across wiki + skills + projects).\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.', {
2555
+ action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
2556
+ path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/projects/... (bare /wiki, /skills, /projects also work; for mv: source)'),
2557
+ to: z.string().optional().describe('[mv] destination path'),
2558
+ query: z.string().optional().describe('[search] term to match against names/content'),
2559
+ content: z.string().optional().describe('[write] inline HTML/markdown/text'),
2560
+ file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only)'),
2561
+ base64: z.string().optional().describe('[write] base64-encoded binary content'),
2562
+ googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] explicit Google Workspace type (also derived from .google-* filename extension)'),
2563
+ title: z.string().optional().describe('[write + googleType] title for a new native file'),
2564
+ ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames ({type,lineHash,newContent}), element ops for excalidraw ({id,x,y,...}), or office ops'),
2565
+ state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
2566
+ recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
2567
+ lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
2568
+ pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
2569
+ org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
2570
+ projectId: PROJECT_OVERRIDE_PARAM,
2571
+ }, async (args) => {
2572
+ const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, recursive, pattern, lines, org } = args;
2573
+ // Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
2574
+ // carry it as the per-request org scope (orgHeader), validate per-root below.
2575
+ // The PATH is the address; the org= param is legacy for bare roots only, and a
2576
+ // contradiction between the two is an error, never a silent winner. A full share
2577
+ // URL (https://…/o/…) is stripped to its pathname first — the pathname IS the fs path.
2578
+ const scoped = splitOrgScope(stripUrlOrigin(rawPath));
2579
+ if (scoped.error) return err(new Error(scoped.error));
2580
+ let p = scoped.path;
2581
+ const orgFromPath = scoped.org;
2582
+ if (org && orgFromPath && String(org).toLowerCase() !== String(orgFromPath).toLowerCase()) {
2583
+ return err(new Error(`org="${org}" contradicts the org addressed by the path (/o/${orgFromPath}/) — address the org in the path only`));
2584
+ }
2585
+ let to = rawTo;
2586
+ if (to) {
2587
+ const toScoped = splitOrgScope(stripUrlOrigin(to));
2588
+ if (toScoped.error) return err(new Error(toScoped.error));
2589
+ if (toScoped.org && orgFromPath && toScoped.org.toLowerCase() !== orgFromPath.toLowerCase()) {
2590
+ return err(new Error('mv stays within one org — source and destination must address the same org'));
2591
+ }
2592
+ if (toScoped.org && !orgFromPath) {
2593
+ return err(new Error('mv to an org-scoped destination needs an org-scoped source too (/o/<org>/... on both sides)'));
2594
+ }
2595
+ to = toScoped.path;
2596
+ }
2597
+ const orgHeader = org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {});
2598
+ const gs = getSessionState().gates;
2599
+
2600
+ // ── Root listing: / or empty → the orgs this session can address (Shape A) ──
2601
+ if (!p || p === '/' || p === '') {
2602
+ 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'));
2603
+ if (orgFromPath) {
2604
+ // ls /o/<org> → that org's three roots
2605
+ return ok([
2606
+ { name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
2607
+ { name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
2608
+ { name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
2609
+ ]);
2610
+ }
2611
+ // ls / → the orgs (id, slug, name) the agent belongs to; org is the top folder
2612
+ const orgs = await getOrgList();
2613
+ if (!orgs.length) return err(new Error('no orgs to list — are you signed in? fs(ls, path="/") shows the orgs you belong to'));
2614
+ return ok(orgs.map(o => ({ name: o.name || o.id, type: 'directory', path: `/o/${o.slug || o.id}`, hint: 'org — fs(ls, path="/o/<org>") shows its wiki/skills/projects' })));
2615
+ }
2616
+
2617
+ // ── mkdir: create a project (a directory in the /projects root) ──
2618
+ if (action === 'mkdir') {
2619
+ if (!p.startsWith('/projects')) {
2620
+ return err(new Error('mkdir is only meaningful under /projects (wiki dirs are implicit; skills are flat) — e.g. fs(mkdir, path="/o/<org>/projects/my-project")'));
2621
+ }
2622
+ // A project create is project-less: it must not guess the org.
2623
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
2624
+ const parts = p.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
2625
+ if (parts.length === 0 || parts.length > 2) {
2626
+ return err(new Error(`mkdir path must be /projects/<name> or /projects/<folder>/<name> — got ${p}`));
2627
+ }
2628
+ const folder = parts.length === 2 ? parts[0] : null;
2629
+ const projectRef = parts[parts.length - 1];
2630
+ const existing = await resolveProjectRef(projectRef).catch(() => null);
2631
+ if (existing) return err(new Error(`project already exists: ${projectRef}`));
2632
+ const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2633
+ const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
2634
+ if (!created?.id) return err(new Error('project create returned no id'));
2635
+ if (folder) {
2636
+ try { await api('PATCH', `/api/project/${created.id}`, { folder }, orgHeader); } catch { /* folder best-effort */ }
2637
+ }
2638
+ projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
2639
+ return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${orgFromPath ? `/o/${orgFromPath}` : ''}/projects${folder ? '/' + folder : ''}/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
2640
+ }
2641
+
2642
+ // ── Root: /wiki/... ────────────────────────────────────────────
2643
+ if (p.startsWith('/wiki')) {
2644
+ const wikiPath = p === '/wiki' || p === '/wiki/' ? '' : p.replace(/^\/wiki\/?/, '');
2645
+ // Project-less mutations must not guess the org (Shape A: address it in the path —
2646
+ // an org-scoped path or org= counts as explicit; a bare path needs a bound org).
2647
+ if (['write', 'edit', 'mv', 'rm'].includes(action)) {
2648
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
2649
+ }
2650
+ switch (action) {
2651
+ case 'ls': {
2652
+ if (!wikiPath) {
2653
+ const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
2654
+ return ok(formatWikiIndex(tree?.pages || []));
2655
+ }
2656
+ const pages = await api('GET', `/api/wiki/tree?prefix=${encodeURIComponent(wikiPath)}`, undefined, orgHeader);
2657
+ return ok(formatWikiIndex(pages?.pages || []));
2658
+ }
2659
+ case 'read': {
2660
+ const canonPath = wikiPath.replace(/\.md$/, '');
2661
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}&raw=true`, undefined, orgHeader);
2662
+ if (!page?.content) return err(new Error(`wiki page not found: ${canonPath}`));
2663
+ return ok(page.content);
2664
+ }
2665
+ case 'write': {
2666
+ if (!content) return err(new Error('write to /wiki/... requires content'));
2667
+ // OKF boundary rule: /a/b.md ≡ a/b — strip a trailing .md so both spellings work.
2668
+ const canonPath = wikiPath.replace(/\.md$/, '');
2669
+ const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
2670
+ const body = { path: canonPath, title: canonPath.split('/').pop(), content, type: existing?.type || 'Page' };
2671
+ const result = existing
2672
+ ? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content }, orgHeader)
2673
+ : await api('POST', '/api/wiki/pages', body, orgHeader);
2674
+ return ok(result || { path: canonPath, written: true });
2675
+ }
2676
+ case 'edit': {
2677
+ if (!ops?.length) return err(new Error('edit requires ops'));
2678
+ const result = await api('POST', '/api/wiki/edit', { path: wikiPath, operations: ops }, orgHeader);
2679
+ return ok(result);
2680
+ }
2681
+ case 'mv': {
2682
+ if (!to || !to.startsWith('/wiki')) return err(new Error('mv within /wiki requires to=/wiki/...'));
2683
+ const result = await api('POST', '/api/wiki/mv', { from: wikiPath, to: to.replace(/^\/wiki\/?/, '') }, orgHeader);
2684
+ return ok(result);
2685
+ }
2686
+ case 'rm': {
2687
+ // Soft delete: move the page under archive/ (agents never hard-delete;
2688
+ // the web UI offers permanent delete in the archive). mv cascades
2689
+ // inbound refs, so links keep working into the archive.
2690
+ const canonPath = wikiPath.replace(/\.md$/, '');
2691
+ const archivePath = canonPath.startsWith('archive/') ? canonPath : `archive/${canonPath}`;
2692
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
2693
+ if (!page?.id) return err(new Error(`wiki page not found: ${canonPath}`));
2694
+ const result = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: archivePath }, orgHeader);
2695
+ return ok({ archived: true, from: canonPath, to: archivePath, ...(result?.referrersUpdated ? { referrersUpdated: result.referrersUpdated } : {}) });
2696
+ }
2697
+ case 'search': {
2698
+ markSearched(gs, 'wiki');
2699
+ const pages = await api('GET', `/api/wiki/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
2700
+ return ok(formatWikiIndex(pages?.pages || pages?.results || []));
2701
+ }
2702
+ default:
2703
+ return err(new Error(`fs ${action} not supported for /wiki`));
2704
+ }
2705
+ }
2706
+
2707
+ // ── Root: /skills/... ──────────────────────────────────────────
2708
+ if (p.startsWith('/skills')) {
2709
+ const slug = p === '/skills' || p === '/skills/' ? '' : p.replace(/^\/skills\/?/, '').split('/')[0];
2710
+ if (['write', 'mv', 'rm'].includes(action)) {
2711
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
2712
+ }
2713
+ switch (action) {
2714
+ case 'ls': {
2715
+ const list = await api('GET', '/api/skills', undefined, orgHeader);
2716
+ const skills = Array.isArray(list) ? list : (list?.skills || []);
2717
+ return ok(skills.map(s => ({ slug: s.slug, name: s.name, description: s.description })));
2718
+ }
2719
+ case 'read': {
2720
+ if (!slug) return err(new Error('read /skills/<slug>'));
2721
+ const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
2722
+ return ok(s?.content || '');
2723
+ }
2724
+ case 'write': {
2725
+ if (!slug || !content) return err(new Error('write /skills/<slug> requires content'));
2726
+ const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
2727
+ if (!existing) {
2728
+ const g2 = g2Block(gs);
2729
+ if (g2) return err(new Error(g2));
2730
+ }
2731
+ const name = existing?.name || slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2732
+ // Derive a description from the slug when the agent didn't pass one
2733
+ // (the API requires a non-empty description on create).
2734
+ const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
2735
+ const result = existing
2736
+ ? await api('PUT', `/api/skills/${existing.id}`, { content }, orgHeader)
2737
+ : await api('POST', '/api/skills', { slug, name, content, description }, orgHeader);
2738
+ // Writing an archived skill restores it (update flow un-archives).
2739
+ if (result?.id && existing?.archived) {
2740
+ try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
2741
+ }
2742
+ return ok(result || { slug, written: true });
2743
+ }
2744
+ case 'mv': {
2745
+ if (!to || !to.startsWith('/skills')) return err(new Error('mv within /skills requires to=/skills/...'));
2746
+ const toSlug = to.replace(/^\/skills\/?/, '').split('/')[0];
2747
+ const result = await api('POST', '/api/skills/fork', { from: slug, to: toSlug, ...(org ? { org } : {}) }, orgHeader);
2748
+ return ok(result);
2749
+ }
2750
+ case 'rm': {
2751
+ const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
2752
+ if (!s?.id) return err(new Error(`skill not found: ${slug}`));
2753
+ return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
2754
+ }
2755
+ case 'search': {
2756
+ markSearched(gs, 'skill');
2757
+ const results = await api('GET', `/api/skills/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
2758
+ const skills = Array.isArray(results) ? results : (results?.skills || []);
2759
+ return ok(skills.map(s => ({ slug: s.slug, name: s.name, description: s.description })));
2760
+ }
2761
+ default:
2762
+ return err(new Error(`fs ${action} not supported for /skills`));
2763
+ }
2764
+ }
2765
+
2766
+ // ── Root: /projects/... ────────────────────────────────────────
2767
+ if (p.startsWith('/projects')) {
2768
+ const parts = p.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
2769
+ // Resolve the project from the path: <folder?>/<project>/<layer>/<lane>/<file>
2770
+ let projectRef = null, layer, lane, filename;
2771
+ if (parts.length === 0) {
2772
+ // /projects or /o/<org>/projects — list projects (archived ones live in the
2773
+ // Archive bin and are hidden from the agent's active list — mirroring the web UI).
2774
+ // An org-scoped root lists ONLY that org's projects (Shape A).
2775
+ const projects = await api('GET', '/api/projects');
2776
+ const active = Array.isArray(projects?.projects)
2777
+ ? { ...projects, projects: projects.projects.filter(x => x.folder !== '__archived') }
2778
+ : projects;
2779
+ if (orgFromPath && Array.isArray(active?.projects)) {
2780
+ const kept = [];
2781
+ for (const x of active.projects) {
2782
+ if (await pathOrgMatches(orgFromPath, x)) kept.push(x);
2783
+ }
2784
+ active.projects = kept;
2785
+ }
2786
+ return ok(active);
2787
+ }
2788
+ if (parts.length >= 4) {
2789
+ projectRef = parts.length === 4 ? parts[0] : parts[1]; // no-folder vs folder form
2790
+ layer = parts[parts.length - 3];
2791
+ lane = parts[parts.length - 2];
2792
+ filename = parts[parts.length - 1];
2793
+ } else if (parts.length === 3) {
2794
+ // /projects/<project>/<layer>/<lane> OR /projects/<project>/<layer>/<file>
2795
+ // A lane view (no extension) vs a layer-root file (has an extension) —
2796
+ // mirror the server's dual routes.
2797
+ projectRef = parts[0];
2798
+ const last = parts[2];
2799
+ if (/\.[a-z0-9]+$/i.test(last) && !['designs', 'research', 'plans', 'copy', 'wireframes', 'images', 'components', 'brand-assets'].includes(last)) {
2800
+ layer = parts[1]; lane = null; filename = last; // layer-root file
2801
+ } else {
2802
+ layer = parts[1]; lane = last; filename = null; // lane view
2803
+ }
2804
+ } else if (parts.length === 2) {
2805
+ projectRef = parts[0]; // /projects/<project>/<layer>
2806
+ layer = parts[1]; lane = null; filename = null;
2807
+ } else if (parts.length === 1) {
2808
+ projectRef = parts[0]; // /projects/<project>
2809
+ }
2810
+
2811
+ const run = async () => {
2812
+ const hasFile = layer && filename;
2813
+ // Layer-root file (lane null) hits the server's 2-segment route.
2814
+ const filePath = hasFile ? (lane ? `${encodeURIComponent(layer)}/${encodeURIComponent(lane)}/${encodeURIComponent(filename)}` : `${encodeURIComponent(layer)}/${encodeURIComponent(filename)}`) : null;
2815
+ const g1Mutating = ['write', 'edit', 'mv', 'rm'].includes(action);
2816
+
2817
+ if (g1Mutating && !gs.wikiSearched) {
2818
+ let wikiIndex = '';
2819
+ try { const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* best-effort */ }
2820
+ return err(new Error(g1Block(gs, wikiIndex)));
2821
+ }
2822
+
2823
+ switch (action) {
2824
+ case 'ls': {
2825
+ // Listing path relative to the project. No-folder forms: [p] → '/',
2826
+ // [p, layer] → '/layer', [p, layer, lane] → '/layer/lane' — a lane/layer
2827
+ // URL IS this path (Q2), so ls of a shared lane URL lists the lane.
2828
+ // (Folder-form ls — [folder, p, layer[, lane]] — is a pre-existing dead
2829
+ // end: the parse takes parts[0] as the project ref and resolve fails.)
2830
+ let lsPath, lsProjectId;
2831
+ if (projectRef && parts.length >= 3) { lsProjectId = projectRef; lsPath = '/' + parts.slice(parts.length - 2).join('/'); }
2832
+ else if (projectRef && parts.length === 2) { lsProjectId = projectRef; lsPath = '/' + parts[1]; }
2833
+ else { lsProjectId = projectRef; lsPath = '/'; }
2834
+ const lsParams = new URLSearchParams({ path: lsPath });
2835
+ if (recursive) { lsParams.set('recursive', 'true'); lsParams.set('summary', 'true'); }
2836
+ if (pattern) lsParams.set('pattern', pattern);
2837
+ const result = await api('GET', `/api/fs/?${lsParams.toString()}`, undefined, orgHeader);
2838
+ return ok(result);
2839
+ }
2840
+ case 'read': {
2841
+ if (!filePath) return err(new Error('read requires a full file path: /projects/<project>/<layer>/<lane>/<file>'));
2842
+ const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
2843
+ const result = await api('GET', `/api/fs/${filePath}${query}`, undefined, orgHeader);
2844
+ if (result?.type === 'binary') return ok({ type: 'binary', path: result.path, contentType: result.contentType });
2845
+ // App frames (drafted:frame-type=app) carry a separate hydration state —
2846
+ // return the state + a content preview rather than the full bundle, which
2847
+ // is usually a large minified JS blob that would blow the tool-result cap.
2848
+ if (result?.id && /drafted:frame-type[^>]*content="app"/.test(result.content || '')) {
2849
+ let state = null;
2850
+ try { const st = await api('GET', `/api/file/${result.id}/state`); state = st?.state ?? null; } catch { /* best-effort */ }
2851
+ return ok({ appFrame: true, frameId: result.id, frameUrl: `${getServerUrl()}/f/${result.id}`, state, contentPreview: (result.content || '').slice(0, 500) });
2852
+ }
2853
+ // Surface the clickable link alongside content so the agent can hand
2854
+ // the user a URL for what it just read (filesystem parity: cat gives
2855
+ // you the path; this gives the path AND the shareable link). Prefer the
2856
+ // semantic /o/<org>/<project>/<path> form when the project context is
2857
+ // available; /f/<uuid> is the always-works fallback.
2858
+ if (result?.id) {
2859
+ const proj = result.project;
2860
+ const semantic = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
2861
+ return ok({ path: result.path, frameUrl: semantic || result.frameUrl || `${getServerUrl()}/f/${result.id}`, content: result.content || '' });
2862
+ }
2863
+ return ok(result?.content || '');
2864
+ }
2865
+ case 'write': {
2866
+ if (!filePath) return err(new Error('write requires a full file path: /projects/<project>/<layer>/<lane>/<file>'));
2867
+ // Google Workspace: explicit googleType OR .google-* filename extension.
2868
+ const extType = /\.google-(doc|sheet|slide)$/.exec(filename || '');
2869
+ const gw = googleType || (extType ? 'google-' + extType[1] : null);
2870
+ // App-frame state write (persist state only, no content change).
2871
+ if (state !== undefined && !content && !file_path && !base64 && !gw) {
2872
+ const f = await api('GET', `/api/fs/${filePath}`, undefined, orgHeader);
2873
+ if (!f?.id) return err(new Error('could not resolve frame for state write'));
2874
+ await api('POST', `/api/file/${f.id}/state`, { state });
2875
+ return ok({ path: p, stateWritten: true, frameId: f.id });
2876
+ }
2877
+ const body = {};
2878
+ if (gw) {
2879
+ // Google Workspace frames are created via the workspace endpoint (the
2880
+ // label drops the .google-* extension), then populated with native actions.
2881
+ const label = basename(filename, extname(filename)) || filename;
2882
+ const result = await api('POST', '/api/google/workspace/create-frame', {
2883
+ projectId: getState().projectId,
2884
+ layer, lane, label, type: gw, title: title || label,
2885
+ }, orgHeader);
2886
+ return ok({ ...result, path: `/${layer}/${lane}/${filename}`, sourceType: gw });
2887
+ }
2888
+ else if (content) body.content = content;
2889
+ else if (file_path) body.file_path = file_path;
2890
+ else if (base64) body.base64 = base64;
2891
+ else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
2892
+ const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
2893
+ if (state !== undefined && result?.id) {
2894
+ try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
2895
+ }
2896
+ return ok(result);
2897
+ }
2898
+ case 'edit': {
2899
+ if (!filePath || !ops?.length) return err(new Error('edit requires a full file path + ops'));
2900
+ const isExcalidraw = filename?.endsWith('.excalidraw');
2901
+ const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
2902
+ const result = isExcalidraw
2903
+ ? await api('POST', '/api/fs/edit-excalidraw', { path: relPath, elements: ops, remove: args.remove }, orgHeader)
2904
+ : await api('POST', '/api/fs/edit', { path: relPath, operations: ops }, orgHeader);
2905
+ return ok(result);
2906
+ }
2907
+ case 'mv': {
2908
+ const from = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
2909
+ const toClean = (to || '').replace(/^\/+|\/+$/g, '');
2910
+ const toHasProject = toClean.startsWith('projects/');
2911
+ const toParts = toClean.replace(/^projects\/?/, '').split('/').filter(Boolean);
2912
+ let toPath, toProjectId;
2913
+ if (toHasProject && toParts.length >= 2) {
2914
+ // Full destination path: first segment after /projects is the project ref
2915
+ // (fs grammar: /projects/<project>/<layer>[/<lane>]/<file>). Drop it from
2916
+ // the path; resolve toProjectId only when it differs from the source.
2917
+ const toRef = toParts[0];
2918
+ const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
2919
+ if (!sameProject) {
2920
+ const toMeta = await resolveProjectRef(toRef).catch(() => null);
2921
+ if (toMeta) toProjectId = toMeta.id;
2922
+ }
2923
+ toPath = '/' + toParts.slice(1).join('/');
2924
+ } else {
2925
+ // Bare relative path (no project prefix): /{layer}[/{lane}]/{file}
2926
+ toPath = toParts.length ? '/' + toParts.join('/') : (to || '');
2927
+ }
2928
+ const result = await api('POST', '/api/fs/mv', { from, to: toPath, ...(toProjectId ? { toProjectId } : {}) }, orgHeader);
2929
+ return ok(result);
2930
+ }
2931
+ case 'rm': {
2932
+ // Project-level rm = archive (soft delete): move to the reserved
2933
+ // __archived folder. Frame/lane rm still deletes those items, but a
2934
+ // whole project is never hard-deleted by an agent — the web UI's
2935
+ // Archive bin is where a human permanently deletes.
2936
+ if (!filePath) {
2937
+ const pid = getState().projectId;
2938
+ if (!pid) return err(new Error('could not resolve project id for archive'));
2939
+ const result = await api('PATCH', `/api/project/${pid}`, { folder: '__archived' }, orgHeader);
2940
+ return ok({ archived: true, project: projectRef, projectId: pid, archiveHint: 'The project now lives in the Archive bin in the web UI sidebar — a human can restore or permanently delete it there.' });
2941
+ }
2942
+ const rmPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
2943
+ const result = await api('DELETE', `/api/fs/${rmPath.replace(/^\//, '')}`, undefined, orgHeader);
2944
+ return ok(result);
2945
+ }
2946
+ case 'search': {
2947
+ const result = await api('GET', `/api/fs/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
2948
+ return ok(result);
2949
+ }
2950
+ default:
2951
+ return err(new Error(`fs ${action} not supported for /projects`));
2952
+ }
2953
+ };
2954
+
2955
+ // Resolve the project from the path and scope the API calls to it
2956
+ // (same seam the old project(action="open") provided, now path-derived).
2957
+ const meta = args.projectId ? await resolveProjectRef(args.projectId) : (projectRef ? await resolveProjectRef(projectRef) : null);
2958
+ // Shape A: the org in the path must address the project's own org. Without
2959
+ // this check, /o/<orgX>/projects/<projInOrgY>/... silently operated on orgY
2960
+ // (the old URL-form alias stripped the org segment without validation).
2961
+ if (orgFromPath && meta && !(await pathOrgMatches(orgFromPath, meta))) {
2962
+ return err(new Error(`project "${projectRef}" is not in the addressed org — /o/${orgFromPath}/... addresses ${orgFromPath}, but the project lives in ${meta.orgSlug || meta.orgId}`));
2963
+ }
2964
+ if (!meta && projectRef && (action === 'write' || action === 'edit')) {
2965
+ // mkdir -p semantics: writing into a non-existent project creates it.
2966
+ // The project name comes from the path; org resolves like any project-less
2967
+ // mutation (explicit org in the path / org= wins, else the bound org, else refused).
2968
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
2969
+ const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2970
+ const body = { name: projectName };
2971
+ if (org) body.templateSlug = undefined;
2972
+ let created;
2973
+ try {
2974
+ created = await api('POST', '/api/projects', body, orgHeader);
2975
+ } catch (e) {
2976
+ return err(new Error(`project "${projectRef}" not found and auto-create failed: ${e?.message || e}`));
2977
+ }
2978
+ if (!created?.id) return err(new Error(`project "${projectRef}" not found and auto-create returned no id`));
2979
+ const createdMeta = { id: created.id, slug: created.slug, name: created.name || projectName, orgId: created.orgId, orgSlug: created.orgSlug };
2980
+ projectRefCache.set(projectRef, createdMeta);
2981
+ projectRefCache.set(created.id, createdMeta);
2982
+ return withProjectOverride(createdMeta, run);
2983
+ }
2984
+ if (projectRef && !meta) return err(new Error(`project not found: ${projectRef} — pass a project slug/name/UUID from /projects, or write to a new path to create it`));
2985
+ return meta ? withProjectOverride(meta, run) : run();
2986
+ }
2987
+
2988
+ 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)`));
2989
+ });
2990
+ function normalizeMcpUpdatePolicy(policy) {
2991
+ const severity = policy?.policy?.severity || 'unknown';
2992
+ const updateAvailable = !!policy?.policy?.updateAvailable;
2993
+ const required = !!policy?.policy?.required;
2994
+ return {
2995
+ status: required ? 'required' : updateAvailable ? 'stale' : severity === 'unknown' ? 'unknown' : 'current',
2996
+ currentVersion: policy?.versions?.client || PACKAGE_VERSION,
2997
+ latestVersion: policy?.versions?.latest || null,
2998
+ recommendedVersion: policy?.versions?.recommended || null,
2999
+ minimumRequiredVersion: policy?.versions?.minimumRequired || null,
3000
+ severity,
3001
+ updateAvailable,
3002
+ stale: updateAvailable,
3003
+ required,
3004
+ reason: policy?.policy?.reason || null,
3005
+ enabled: policy?.policy?.enabled !== false,
3006
+ mode: policy?.package?.mcpMode || mcpMode(),
3007
+ distribution: policy?.package?.distribution || (mcpMode() === 'stdio' ? 'npm-stdio' : 'hosted-http'),
3008
+ update: {
3009
+ command: policy?.update?.command || null,
3010
+ helper: policy?.update?.helper || null,
3011
+ packageManager: policy?.update?.packageManager || 'npm',
3012
+ },
3013
+ restart: {
3014
+ required: !!policy?.restart?.required,
3015
+ guidance: policy?.restart?.guidance || null,
3016
+ },
3017
+ checkedAt: policy?.serverTimestamp || null,
3018
+ };
3019
+ }
3020
+
3021
+ function buildInstalledMcpUpdateInstructions(updateMetadata = null) {
3022
+ const mode = updateMetadata?.mode || mcpMode();
3023
+ const restart = updateMetadata?.restart || {
3024
+ required: mode === 'stdio',
3025
+ guidance: 'Restart agents after updating the npm-installed Drafted MCP daemon.',
3026
+ };
3027
+
3028
+ if (mode !== 'stdio') {
3029
+ return {
3030
+ action: 'update_mcp',
3031
+ started: false,
3032
+ updateSupported: false,
3033
+ mode,
3034
+ currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
3035
+ latestVersion: updateMetadata?.latestVersion || null,
3036
+ updateAvailable: false,
3037
+ required: false,
3038
+ command: null,
3039
+ dryRunCommand: null,
3040
+ manualCommand: null,
3041
+ restart: {
3042
+ required: false,
3043
+ guidance: restart.guidance || 'Hosted HTTP MCP updates with the Drafted server deploy.',
3044
+ },
3045
+ note: 'This session is using hosted HTTP MCP, so there is no npm-installed stdio daemon to update on this machine.',
3046
+ };
3047
+ }
3048
+
3049
+ const server = getServerUrl().replace(/\/$/, '');
3050
+ const manualCommand = platform() === 'win32'
3051
+ ? `$tmp = Join-Path $env:TEMP "drafted-install.ps1"; Invoke-WebRequest -UseBasicParsing "${server}/install.ps1" -OutFile $tmp; powershell -NoProfile -ExecutionPolicy Bypass -File $tmp`
3052
+ : `tmp=$(mktemp); curl -fsSL ${server}/install.sh -o "$tmp" && bash "$tmp"`;
3053
+
3054
+ return {
3055
+ action: 'update_mcp',
3056
+ started: false,
3057
+ updateSupported: true,
3058
+ mode: 'stdio',
3059
+ currentVersion: updateMetadata?.currentVersion || PACKAGE_VERSION,
3060
+ latestVersion: updateMetadata?.latestVersion || null,
3061
+ recommendedVersion: updateMetadata?.recommendedVersion || null,
3062
+ minimumRequiredVersion: updateMetadata?.minimumRequiredVersion || null,
3063
+ updateAvailable: !!updateMetadata?.updateAvailable,
3064
+ required: !!updateMetadata?.required,
3065
+ command: 'drafted update --yes',
3066
+ dryRunCommand: 'drafted update --dry-run',
3067
+ manualCommand,
3068
+ restart: {
3069
+ required: true,
3070
+ guidance: restart.guidance || 'Restart agents after updating the npm-installed Drafted MCP daemon.',
3071
+ },
3072
+ note: 'This action is intentionally advisory: it does not replace the currently running MCP process. Run the command, then restart your agent/editor so it starts the updated drafted-mcp.',
3073
+ mcpUpdate: updateMetadata || null,
3074
+ };
3075
+ }
3076
+
3077
+ async function getMcpUpdateMetadata() {
3078
+ const mode = mcpMode();
3079
+ try {
3080
+ const query = new URLSearchParams({
3081
+ cliVersion: PACKAGE_VERSION,
3082
+ mcpMode: mode,
3083
+ });
3084
+ const policy = await api('GET', `/api/installations/latest?${query.toString()}`);
3085
+ return normalizeMcpUpdatePolicy(policy);
3086
+ } catch (error) {
3087
+ return {
3088
+ status: 'unknown',
3089
+ currentVersion: PACKAGE_VERSION,
3090
+ latestVersion: null,
3091
+ recommendedVersion: null,
3092
+ minimumRequiredVersion: null,
3093
+ severity: 'unknown',
3094
+ updateAvailable: false,
3095
+ stale: false,
3096
+ required: false,
3097
+ reason: 'latest_check_failed',
3098
+ enabled: false,
3099
+ mode,
3100
+ distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
3101
+ update: { command: null, helper: null, packageManager: 'npm' },
3102
+ restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
3103
+ checkedAt: null,
3104
+ };
3105
+ }
3106
+ }
3107
+
3108
+ // Process-lifetime cache: the update status rides on every `whoami` (meant to be called
3109
+ // once per session), so avoid a network round-trip on repeat calls. `get_org` shares the
3110
+ // cache too (same underlying data).
3111
+ let mcpUpdateCache = null; // { data, fetchedAt }
3112
+ const MCP_UPDATE_CACHE_MS = 5 * 60_000;
3113
+ async function getCachedMcpUpdateMetadata() {
3114
+ if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
3115
+ const data = await getMcpUpdateMetadata();
3116
+ mcpUpdateCache = { data, fetchedAt: Date.now() };
3117
+ return data;
3118
+ }
4861
3119
  return server;
4862
3120
  }
4863
3121