drafted 1.21.3 → 1.22.1

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 +45 -1
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -484,6 +484,8 @@ const TOOL_ANNOTATIONS = {
484
484
  // Google Drive mirror triage. Mutating (resolve/retry_all/sync rewrite frame
485
485
  // content and re-push files) and openWorld (it reaches Google).
486
486
  drive: { title: 'Drive conflicts', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
487
+ // Org roster — read-only; the lookup an agent needs before assigning a task.
488
+ members: { title: 'Members', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'The org roster — who you can assign work to and @mention. Org members only; read-only.' },
487
489
  repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), rescan (re-fetch the tracked branch), entries (search the index). Linking and unlinking are NOT here — a human does them in the Drafted UI. Content stays in git; Drafted keeps a read-only index.' },
488
490
  };
489
491
 
@@ -3345,7 +3347,7 @@ server.resource('info', 'drafted://info', {
3345
3347
  };
3346
3348
  });
3347
3349
 
3348
- 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. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `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, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
3350
+ 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: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `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, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
3349
3351
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3350
3352
  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)'),
3351
3353
  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'),
@@ -4452,6 +4454,48 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
4452
4454
  return err(new Error(`unknown repo action: ${action}`));
4453
4455
  });
4454
4456
 
4457
+ tool('members', 'The org roster — who you can assign work to and @mention. `action=list` returns each member\'s `{ userId, email, name, role }`; assign a task by putting a member\'s EMAIL in `drafted:assignee:` (or pass the userId). This is the lookup that was missing: a task assigned to a bare human name is REJECTED on write with the candidate list, so look the person up here first. ORG MEMBERS ONLY — an external project collaborator gets 403, never the roster. `email` is both the address and the @mention handle; `name` is usually the same as the email because usernames are emails. Read-only.', {
4458
+ action: z.enum(['list']).describe('list: the org roster.'),
4459
+ org: z.string().optional().describe('Org (id, slug or name) to scope this call; defaults to the session\'s working org. It must be an org you are a MEMBER of.'),
4460
+ query: z.string().optional().describe('filter by name or email (case-insensitive substring).'),
4461
+ limit: z.number().int().min(1).max(100).optional().describe('page size (default 25, max 100).'),
4462
+ offset: z.number().int().min(0).optional().describe('page offset (default 0).'),
4463
+ compact: z.boolean().optional().describe('return only {userId,email,role}.'),
4464
+ }, async ({ action, org, query, limit, offset, compact }) => {
4465
+ try {
4466
+ if (action !== 'list') return err(new Error(`unknown members action: ${action}`));
4467
+ const orgs = await getOrgList();
4468
+ if (!orgs.length) return err(new Error('no orgs — are you signed in? Run whoami.'));
4469
+ const want = String(org || workingOrgId() || '').toLowerCase();
4470
+ const target =
4471
+ orgs.find((o) => [o.id, o.slug, o.name].filter(Boolean).some((v) => String(v).toLowerCase() === want)) ||
4472
+ (orgs.length === 1 ? orgs[0] : null);
4473
+ if (!target) {
4474
+ return err(new Error(`Which org? You belong to ${orgs.length}: ${orgs.map((o) => o.slug || o.name || o.id).join(', ')}. Pass org="<slug>".`));
4475
+ }
4476
+ const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
4477
+ const start = Math.max(0, Math.floor(Number(offset) || 0));
4478
+ const rows = await api('GET', `/api/orgs/${encodeURIComponent(target.id)}/members`, undefined, { 'X-Drafted-Org': target.id });
4479
+ const all = Array.isArray(rows) ? rows : [];
4480
+ const q = query ? String(query).toLowerCase() : '';
4481
+ const filtered = q ? all.filter((m) => `${m.username || ''} ${m.email || ''}`.toLowerCase().includes(q)) : all;
4482
+ const page = filtered.slice(start, start + cap);
4483
+ return ok({
4484
+ org: { id: target.id, slug: target.slug, name: target.name },
4485
+ totalAvailable: filtered.length,
4486
+ offset: start,
4487
+ returned: page.length,
4488
+ truncated: start + page.length < filtered.length,
4489
+ members: page.map((m) => compact
4490
+ ? { userId: m.userId, email: m.email, role: m.role }
4491
+ : { userId: m.userId, email: m.email, name: m.username || null, role: m.role }),
4492
+ note: 'Assign by email: `drafted:assignee: <email>` (or `<userId>`). Never a bare name — an unresolvable assignee is rejected with candidates.',
4493
+ });
4494
+ } catch (e) {
4495
+ return err(e);
4496
+ }
4497
+ });
4498
+
4455
4499
  tool('drive', 'The org\'s Google Drive mirror: its state, and the conflicts a human would otherwise clear by hand one button at a time. Org-wide (org members only) — dispatch by `action`:\n- `status` — connection mode (`dwd` = domain-wide delegation, `oauth` = per-user), root folder, sync/pull job state, and OPEN CONFLICT COUNTS BY TYPE. Start here.\n- `conflicts` — the open conflicts (paginated, `compact` mode). Each carries `conflictType`, the Drive error, the frame label and its ids, so you can decide per file instead of guessing.\n- `resolve` — decide ONE conflict: `keep_drafted` (re-push Drafted content to Drive), `keep_drive` (pull Drive content into the frame, with a version snapshot), `duplicate_both`, `retry` (re-check on the next sync, content untouched), `ignore_once`, `archive_mirror` (stop mirroring this file).\n- `retry_all` — close EVERY open `inaccessible_file` conflict as `retry` and queue a sync. This is the right move for a pile of access failures: a broken credential files one conflict per frame, and re-checking is not a decision anyone should make hundreds of times. Files that are genuinely gone come back as fresh, accurate conflicts.\n- `sync` — queue the org push, then re-read `status`.\n\nTRIAGE ORDER that avoids wasted decisions: `status` → if `inaccessible_file` dominates, `retry_all` → `sync` → `conflicts` again, and only then decide the survivors one by one. `archive_mirror` on a stale access failure deactivates a HEALTHY mirror — prefer `retry` unless the file is really gone.', {
4456
4500
  action: z.enum(['status', 'conflicts', 'resolve', 'retry_all', 'sync']).describe('status: connection + conflict counts; conflicts: list them; resolve: decide one; retry_all: bulk-retry access failures; sync: queue the org push.'),
4457
4501
  conflictId: z.string().optional().describe('[resolve] the conflict id from `conflicts`.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.21.3",
3
+ "version": "1.22.1",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [