drafted 1.19.38 → 1.19.40
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 +15 -1
- package/package.json +1 -1
- package/src/shared/task-id.mjs +54 -0
package/mcp/server.mjs
CHANGED
|
@@ -1653,6 +1653,7 @@ async function resolveProjectArg(projectArg, { required = true } = {}) {
|
|
|
1653
1653
|
* /projects/<folder>/<project>/<layer>/<lane>/<file>
|
|
1654
1654
|
* /projects/<project>/<layer>/<file> (layer-root file)
|
|
1655
1655
|
* /o/<org>/projects/<project>/<layer>/<lane>/<file> (URL form)
|
|
1656
|
+
* /o/<org>[/<folder>]/tasks/<lane?>/<file> | /tasks/... (tasks root)
|
|
1656
1657
|
* /f/<uuid> | bare UUID
|
|
1657
1658
|
* Returns { frameId } or throws with a helpful message.
|
|
1658
1659
|
*/
|
|
@@ -1666,6 +1667,19 @@ async function resolveFsFramePath(path) {
|
|
|
1666
1667
|
let fsPath = p;
|
|
1667
1668
|
const urlForm = p.match(/^\/o\/[^/]+\/projects\/(.+)$/);
|
|
1668
1669
|
if (urlForm) fsPath = '/projects/' + urlForm[1];
|
|
1670
|
+
// Tasks form. This is the address Drafted PRINTS for a task (the /tasks root,
|
|
1671
|
+
// not the system project behind it), so it has to resolve here too — a printed
|
|
1672
|
+
// path that this tool rejects is the worst kind of dead end. Checked after the
|
|
1673
|
+
// /projects form so the raw `/projects/tasks/tasks/...` URL still works.
|
|
1674
|
+
const taskForm = !urlForm && (p.match(/^\/o\/([^/]+)\/(?:(.+?)\/)?tasks\/(.+)$/) || p.match(/^()()\/tasks\/(.+)$/));
|
|
1675
|
+
if (taskForm) {
|
|
1676
|
+
const sys = await api('POST', '/api/tasks/project', { create: false }, {
|
|
1677
|
+
...(taskForm[1] ? { 'X-Drafted-Org': decodeURIComponent(taskForm[1]) } : {}),
|
|
1678
|
+
...(taskForm[2] ? { 'X-Drafted-Folder': taskForm[2] } : {}),
|
|
1679
|
+
}).catch((e) => (e.status === 404 ? null : Promise.reject(e)));
|
|
1680
|
+
if (!sys?.id) throw new Error(`Not found: ${path} — this org has no tasks yet.`);
|
|
1681
|
+
fsPath = `/projects/${sys.id}/tasks/${taskForm[3]}`;
|
|
1682
|
+
}
|
|
1669
1683
|
if (!fsPath.startsWith('/projects')) {
|
|
1670
1684
|
throw new Error('Target must be a frame URL, frame ID, or path /projects/<project>/<layer>/<lane>/<file>');
|
|
1671
1685
|
}
|
|
@@ -3254,7 +3268,7 @@ server.resource('info', 'drafted://info', {
|
|
|
3254
3268
|
};
|
|
3255
3269
|
});
|
|
3256
3270
|
|
|
3257
|
-
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.', {
|
|
3258
3272
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
3259
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)'),
|
|
3260
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'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.40",
|
|
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
|
+
}
|