drafted 1.23.0 → 1.24.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.
Files changed (2) hide show
  1. package/mcp/server.mjs +46 -7
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -20,7 +20,7 @@ import { z } from 'zod';
20
20
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
21
21
  import WebSocket from 'ws';
22
22
  import { LAYERS } from '../src/shared/constants.mjs';
23
- import { liftFolderScope, normalizeFolder, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
23
+ import { liftFolderScope, normalizeFolder, rootEntries, splitFolderScope, subfolderEntries } from '../src/shared/folder-path.mjs';
24
24
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
25
25
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
26
26
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
@@ -3330,10 +3330,10 @@ server.resource('info', 'drafted://info', {
3330
3330
  };
3331
3331
  });
3332
3332
 
3333
- tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\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 — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.', {
3333
+ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\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 — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.', {
3334
3334
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3335
3335
  path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
3336
- to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
3336
+ to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a wiki page (/o/<org>[/<folder>]/wiki/<page>), a skill (/o/<org>/skills/<slug>), a task, a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
3337
3337
  url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
3338
3338
  query: z.string().optional().describe('[search] term to match against names/content. The path must name an org (/o/<org>) unless you belong to exactly one.'),
3339
3339
  content: z.string().optional().describe('[write] inline HTML/markdown/text'),
@@ -3383,10 +3383,22 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3383
3383
  // is the folder chain, everything after is the path within that root. The
3384
3384
  // folder rides the REQUEST (X-Drafted-Folder); it is never stored, because a
3385
3385
  // "current folder" would be the retired org cursor rebuilt one level down.
3386
- const pScope = liftFolderScope(p, to);
3386
+ // link/unlink: a wiki page or skill on EITHER end makes `to` an org-relative
3387
+ // address the server resolves whole, so it keeps its folder chain (linkTo)
3388
+ // and skips the rule that a move stays within one folder. A frame/task source
3389
+ // linking to a frame/task/project still goes through the per-project
3390
+ // resolver, which takes no folder — there the folder rule must stay, or a
3391
+ // cross-folder target silently resolves in the source's folder.
3392
+ const graphRoot = (x) => /^\/(wiki|skills)(\/|$)/.test(splitFolderScope(x).rest || '');
3393
+ const linkTo = (action === 'link' || action === 'unlink') && to && (graphRoot(to) || graphRoot(p)) ? to : null;
3394
+ const pScope = liftFolderScope(p, linkTo ? undefined : to);
3387
3395
  if (pScope.error) return err(new Error(`${action} ${pScope.error}`));
3388
3396
  const folder = pScope.folder;
3389
- to = pScope.to;
3397
+ to = linkTo ? (splitFolderScope(linkTo).rest || linkTo) : pScope.to;
3398
+ // A wiki page or skill target goes to the server as an org-relative address
3399
+ // (/[<folder>/]wiki/<page>, /skills/<slug>), resolved there by the same
3400
+ // resolver that parses links out of content.
3401
+ const linkToAddress = linkTo && /^\/(wiki|skills)\//.test(splitFolderScope(linkTo).rest) ? linkTo : null;
3390
3402
  const orgHeader = {
3391
3403
  ...(org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {})),
3392
3404
  ...(folder ? { 'X-Drafted-Folder': folder } : {}),
@@ -3634,15 +3646,34 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3634
3646
  return ok({ created: true, name: created.name, slug: created.slug, id: created.id, folder: projectFolder || null, path: `${folderBase}/projects/${createdPath}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${createdPath}` });
3635
3647
  }
3636
3648
 
3649
+ // links / link / unlink with a wiki page or skill as the node: one server
3650
+ // route (/api/links) for both, addressed by org-relative path.
3651
+ const graphAction = async (verb, node) => {
3652
+ if (verb === 'links') {
3653
+ return ok(await api('GET', `/api/links?node=${encodeURIComponent(node)}`, undefined, orgHeader));
3654
+ }
3655
+ if (!args.url && !linkTo) {
3656
+ return err(new Error(`${verb} needs a target: to="<wiki page, skill, task, frame or project path>" or url="https://..."`));
3657
+ }
3658
+ const body = { from: node, ...(args.url ? { url: args.url } : { to: linkTo }) };
3659
+ return ok(await api('POST', verb === 'link' ? '/api/links' : '/api/links/unlink', body, orgHeader));
3660
+ };
3661
+
3637
3662
  // ── Root: /wiki/... ────────────────────────────────────────────
3638
3663
  if (p.startsWith('/wiki')) {
3639
3664
  const wikiPath = p === '/wiki' || p === '/wiki/' ? '' : p.replace(/^\/wiki\/?/, '');
3640
3665
  // Project-less mutations must not guess the org (Shape A: address it in the path —
3641
3666
  // an org-scoped path or org= counts as explicit; a bare path needs a bound org).
3642
- if (['write', 'edit', 'mv', 'rm'].includes(action)) {
3667
+ if (['write', 'edit', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
3643
3668
  await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3644
3669
  }
3645
3670
  switch (action) {
3671
+ case 'links':
3672
+ case 'link':
3673
+ case 'unlink': {
3674
+ if (!wikiPath) return err(new Error(`${action} needs a wiki page: /wiki/<page>`));
3675
+ return graphAction(action, `${folder ? '/' + folder : ''}/wiki/${wikiPath.replace(/\.md$/, '')}`);
3676
+ }
3646
3677
  case 'ls': {
3647
3678
  if (!wikiPath) {
3648
3679
  const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
@@ -3733,10 +3764,16 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3733
3764
  if (p.startsWith('/skills')) {
3734
3765
  const { slug, filePath, isSkillMd } = splitSkillPath(p);
3735
3766
  const fileUrl = (id) => `/api/skills/${id}/files/${filePath.split('/').map(encodeURIComponent).join('/')}`;
3736
- if (['write', 'mv', 'rm'].includes(action)) {
3767
+ if (['write', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
3737
3768
  await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3738
3769
  }
3739
3770
  switch (action) {
3771
+ case 'links':
3772
+ case 'link':
3773
+ case 'unlink': {
3774
+ if (!slug) return err(new Error(`${action} needs a skill: /skills/<slug>`));
3775
+ return graphAction(action, `${folder ? '/' + folder : ''}/skills/${slug}`);
3776
+ }
3740
3777
  case 'ls': {
3741
3778
  // A specific slug is a targeted ls: the skill's directory — SKILL.md
3742
3779
  // plus its supporting files, so a README is discoverable.
@@ -4333,6 +4370,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4333
4370
  }
4334
4371
  if (args.url) {
4335
4372
  body.url = args.url;
4373
+ } else if (linkToAddress) {
4374
+ body.toAddress = linkToAddress;
4336
4375
  } else if (to) {
4337
4376
  // Same destination grammar as mv, through the SAME resolver: a
4338
4377
  // /projects/<...>/<layer>/... path is a frame in another project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "bin": {