drafted 1.19.39 → 1.19.41

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
@@ -3268,7 +3268,7 @@ server.resource('info', 'drafted://info', {
3268
3268
  };
3269
3269
  });
3270
3270
 
3271
- 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.\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.', {
3271
+ 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.', {
3272
3272
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3273
3273
  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)'),
3274
3274
  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'),
@@ -4007,9 +4007,16 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4007
4007
  // semantic /o/<org>/<project>/<path> form when the project context is
4008
4008
  // available; /f/<uuid> is the always-works fallback.
4009
4009
  if (result?.id) {
4010
+ // THE SERVER'S frameUrl WINS. It is built by buildFramePath, the one
4011
+ // place that knows a frame's canonical shape — including that a task
4012
+ // addresses as /o/<org>/tasks/<lane>/<file> and NOT through the system
4013
+ // project behind it. This used to compose /projects/<slug><path> here
4014
+ // and PREFER it, so a task read came back pointing at the project
4015
+ // canvas instead of the tasks view: the same URL defect, fixed on the
4016
+ // server, re-created by a second writer that outranked the first.
4010
4017
  const proj = result.project;
4011
- const semantic = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
4012
- return ok({ path: result.path, frameUrl: semantic || result.frameUrl || `${getServerUrl()}/f/${result.id}`, content: result.content || '' });
4018
+ const local = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
4019
+ return ok({ path: result.path, frameUrl: result.frameUrl || local || `${getServerUrl()}/f/${result.id}`, content: result.content || '' });
4013
4020
  }
4014
4021
  return ok(result?.content || '');
4015
4022
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.39",
3
+ "version": "1.19.41",
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": [
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The human-readable task id: BEO-42.
3
+ *
4
+ * ONE file knows this format. The server mints it, the tasks view renders it,
5
+ * the resolver route parses it, and a test pins it — all from here, because a
6
+ * second implementation of "what does BEO-42 mean" is the two-writers-drift trap
7
+ * with an identifier on the line.
8
+ *
9
+ * Shape: <PREFIX>-<n>. The prefix is letters/digits, stored per org
10
+ * (organizations.task_prefix). The number is per-org and monotonic
11
+ * (organizations.next_task_number), so it also sorts by age.
12
+ */
13
+
14
+ const FALLBACK_PREFIX = "TASK";
15
+
16
+ /**
17
+ * Derive an org's prefix from its name. Called ONCE per org, at first mint; the
18
+ * result is stored, because re-deriving it would renumber every existing task
19
+ * the day someone renames the org.
20
+ *
21
+ * Letters and digits only, uppercased, first three — "Beoflow" -> BEO,
22
+ * "Acme Widgets" -> ACM, "3M" -> 3M. A name with nothing usable in it (emoji,
23
+ * CJK, punctuation) falls back to TASK rather than producing an empty prefix,
24
+ * because "-42" is not an id.
25
+ */
26
+ export function taskPrefixFrom(name) {
27
+ const cleaned = String(name || "")
28
+ .toUpperCase()
29
+ .replace(/[^A-Z0-9]/g, "");
30
+ return cleaned ? cleaned.slice(0, 3) : FALLBACK_PREFIX;
31
+ }
32
+
33
+ /** Render: ("BEO", 42) -> "BEO-42". Null/0 number means "not a task" -> null. */
34
+ export function formatTaskId(prefix, number) {
35
+ if (!number || !Number.isFinite(Number(number))) return null;
36
+ return (prefix || FALLBACK_PREFIX) + "-" + Number(number);
37
+ }
38
+
39
+ /**
40
+ * Parse "beo-42", "BEO-42", " BEO-42 " -> { prefix: "BEO", number: 42 }.
41
+ * Returns null for anything else. Case-insensitive on purpose: this is typed by
42
+ * a human into a URL or a search box, and rejecting "beo-42" would be a lookup
43
+ * that fails for the reason nobody guesses.
44
+ *
45
+ * The prefix is NOT checked against the org here — the caller does that, because
46
+ * only the caller knows which org it is resolving in.
47
+ */
48
+ export function parseTaskId(value) {
49
+ const m = String(value || "").trim().match(/^([A-Za-z0-9]{1,16})-(\d{1,9})$/);
50
+ if (!m) return null;
51
+ const number = Number(m[2]);
52
+ if (!number) return null;
53
+ return { prefix: m[1].toUpperCase(), number };
54
+ }