drafted 1.19.34 → 1.19.36
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 +204 -82
- package/mcp/test-org-guards.mjs +25 -1
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { z } from 'zod';
|
|
|
19
19
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
|
-
import { liftFolderScope, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
|
|
22
|
+
import { liftFolderScope, normalizeFolder, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
|
|
23
23
|
import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
24
24
|
import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
|
|
25
25
|
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
@@ -211,6 +211,28 @@ export function rmScope(layer, lane, filename) {
|
|
|
211
211
|
return 'project';
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// The segments AFTER the project in an fs path: '' | layer | layer/lane |
|
|
215
|
+
// layer/file | layer/lane/file. A trailing segment with an extension is a
|
|
216
|
+
// layer-root file, one without is a lane — mirroring the server's dual routes;
|
|
217
|
+
// the excluded names are layers, which would otherwise read as files.
|
|
218
|
+
//
|
|
219
|
+
// Top-level and exported for the same reason as rmScope: it is the whole parse,
|
|
220
|
+
// it is pure, and it used to be positional guesswork spread across four
|
|
221
|
+
// `parts.length ===` branches that silently mis-read every folder-qualified path.
|
|
222
|
+
export function splitProjectTail(tail) {
|
|
223
|
+
if (tail.length === 0) return { layer: undefined, lane: null, filename: null };
|
|
224
|
+
if (tail.length === 1) return { layer: tail[0], lane: null, filename: null };
|
|
225
|
+
if (tail.length === 2) {
|
|
226
|
+
const last = tail[1];
|
|
227
|
+
const isFile = /\.[a-z0-9]+$/i.test(last)
|
|
228
|
+
&& !['designs', 'research', 'plans', 'copy', 'wireframes', 'images', 'components', 'brand-assets'].includes(last);
|
|
229
|
+
return isFile
|
|
230
|
+
? { layer: tail[0], lane: null, filename: last }
|
|
231
|
+
: { layer: tail[0], lane: last, filename: null };
|
|
232
|
+
}
|
|
233
|
+
return { layer: tail[tail.length - 3], lane: tail[tail.length - 2], filename: tail[tail.length - 1] };
|
|
234
|
+
}
|
|
235
|
+
|
|
214
236
|
// ── Org-ambiguity policy (the one decision core) ─────────────────
|
|
215
237
|
// The org guard inside the factory plumbs session/HTTP state into this
|
|
216
238
|
// side-effect-free predicate, which IS the policy (DRAFT-36 "one rule"). Top-level
|
|
@@ -1556,12 +1578,42 @@ async function resolveProjectRef(ref) {
|
|
|
1556
1578
|
list.find(x => String(x.slug || '').toLowerCase() === want) ||
|
|
1557
1579
|
list.find(x => String(x.name || '').toLowerCase() === want);
|
|
1558
1580
|
if (!p) return null;
|
|
1559
|
-
|
|
1581
|
+
// `folder` rides along so a caller can rebuild the project's printed address
|
|
1582
|
+
// (/o/<org>/projects/<folder>/<slug>) without a second lookup.
|
|
1583
|
+
const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null, folder: p.folder || '' };
|
|
1560
1584
|
projectRefCache.set(ref, meta);
|
|
1561
1585
|
projectRefCache.set(p.id, meta);
|
|
1562
1586
|
return meta;
|
|
1563
1587
|
}
|
|
1564
1588
|
|
|
1589
|
+
/**
|
|
1590
|
+
* Find the project in a FOLDER-QUALIFIED path — the form fs(ls) prints,
|
|
1591
|
+
* /projects/<folder...>/<project>/... — by locating the first segment that names
|
|
1592
|
+
* a project WHOSE FOLDER is the segments before it. Checking the folder is what
|
|
1593
|
+
* keeps this from turning a typo into a wrong project: `/projects/Marketing/x`
|
|
1594
|
+
* resolves only if `x` really lives in Marketing.
|
|
1595
|
+
*
|
|
1596
|
+
* One /api/projects fetch for the whole scan, not one per candidate.
|
|
1597
|
+
*/
|
|
1598
|
+
async function resolveFolderQualifiedProject(parts) {
|
|
1599
|
+
const data = await api('GET', '/api/projects').catch(() => null);
|
|
1600
|
+
const list = Array.isArray(data?.projects) ? data.projects : [];
|
|
1601
|
+
const norm = (s) => String(s || '').toLowerCase();
|
|
1602
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1603
|
+
const want = norm(parts[i]);
|
|
1604
|
+
const folderRef = norm(parts.slice(0, i).join('/'));
|
|
1605
|
+
const p = list.find(x =>
|
|
1606
|
+
(x.id === parts[i] || norm(x.slug) === want || norm(x.name) === want) &&
|
|
1607
|
+
norm(x.folder) === folderRef);
|
|
1608
|
+
if (p) {
|
|
1609
|
+
const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null, folder: p.folder || '' };
|
|
1610
|
+
projectRefCache.set(p.id, meta);
|
|
1611
|
+
return { index: i, meta };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1565
1617
|
/**
|
|
1566
1618
|
* Resolve a project argument to its meta, accepting the pseudo-filesystem path
|
|
1567
1619
|
* form (/projects/<name> or /projects/<folder>/<name>) OR a bare slug / name /
|
|
@@ -3197,10 +3249,10 @@ server.resource('info', 'drafted://info', {
|
|
|
3197
3249
|
};
|
|
3198
3250
|
});
|
|
3199
3251
|
|
|
3200
|
-
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,
|
|
3252
|
+
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.', {
|
|
3201
3253
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
3202
3254
|
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)'),
|
|
3203
|
-
to: z.string().optional().describe('[mv] destination path; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
3255
|
+
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'),
|
|
3204
3256
|
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)'),
|
|
3205
3257
|
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.'),
|
|
3206
3258
|
content: z.string().optional().describe('[write] inline HTML/markdown/text'),
|
|
@@ -3275,8 +3327,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3275
3327
|
// Name the org, or don't search. See searchNeedsOrg: an unaddressed search
|
|
3276
3328
|
// returns "no matches" from ONE org and reads as "nowhere", which is the
|
|
3277
3329
|
// one wrong answer a search can give that nothing announces.
|
|
3330
|
+
const orgs = await getOrgList();
|
|
3278
3331
|
{
|
|
3279
|
-
const orgs = await getOrgList();
|
|
3280
3332
|
if (searchNeedsOrg({ explicitOrg: org, orgFromPath, orgCount: orgs.length })) {
|
|
3281
3333
|
// Work is almost always org-specific, and this session usually already
|
|
3282
3334
|
// knows WHICH — a binding, an open project, or the linked repo of the
|
|
@@ -3312,7 +3364,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3312
3364
|
));
|
|
3313
3365
|
}
|
|
3314
3366
|
}
|
|
3315
|
-
|
|
3367
|
+
// THE CANONICAL SLUG, not the spelling the caller used. /o/beoflow resolves
|
|
3368
|
+
// (the org NAME is a resolution rung alongside id and slug) but its slug is
|
|
3369
|
+
// beoflow-mnczl0zt — and fs(ls) already prints that. Echoing the input
|
|
3370
|
+
// instead made ls and search disagree about the address of one thing, which
|
|
3371
|
+
// is the same defect as dropping the folder segment, one axis over.
|
|
3372
|
+
// Computed HERE, before any section renders: section() runs its renderer
|
|
3373
|
+
// immediately, so a later assignment would leave wiki + skills on the old
|
|
3374
|
+
// spelling while projects + frames used the new one.
|
|
3375
|
+
const addressed = orgFromPath || org || null;
|
|
3376
|
+
const canonicalOrg = addressed
|
|
3377
|
+
? (orgs.find(o => o.id === addressed
|
|
3378
|
+
|| (o.slug || '').toLowerCase() === String(addressed).toLowerCase()
|
|
3379
|
+
|| (o.name || '').toLowerCase() === String(addressed).toLowerCase())?.slug || addressed)
|
|
3380
|
+
: null;
|
|
3381
|
+
const scope = canonicalOrg ? `/o/${canonicalOrg}` : '';
|
|
3316
3382
|
const leg = async (fn) => { try { return { v: await fn() }; } catch (e) { return { e: e?.message || String(e) }; } };
|
|
3317
3383
|
const [wiki, skills, projects, frames, repos] = await Promise.all([
|
|
3318
3384
|
leg(() => api('GET', `/api/wiki/search?q=${encodeURIComponent(q)}&limit=10`, undefined, orgHeader)),
|
|
@@ -3391,7 +3457,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3391
3457
|
const projectRows = (Array.isArray(projects.v?.projects) ? projects.v.projects : [])
|
|
3392
3458
|
.filter(x => x.folder !== '__archived');
|
|
3393
3459
|
const folderById = new Map(projectRows.map(x => [x.id, x.folder || '']));
|
|
3394
|
-
const orgAs =
|
|
3460
|
+
const orgAs = canonicalOrg;
|
|
3395
3461
|
section('Projects', projects, () => {
|
|
3396
3462
|
const hits = projectRows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q)).slice(0, 10);
|
|
3397
3463
|
return hits.length
|
|
@@ -3414,7 +3480,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3414
3480
|
out.push('Marked [git: …] = wiki/skill owned by that repo; edit via commit. Projects and tasks in the same folder write normally.');
|
|
3415
3481
|
}
|
|
3416
3482
|
|
|
3417
|
-
const searched =
|
|
3483
|
+
const searched = canonicalOrg;
|
|
3418
3484
|
// ALWAYS say where this looked. "No matches" is only safe to act on when
|
|
3419
3485
|
// the reader can see the scope it was computed over.
|
|
3420
3486
|
return ok(`Search "${q}" in ${searched ? `/o/${searched}` : 'your only org'}\n\n${out.join('\n\n')}`);
|
|
@@ -3744,6 +3810,19 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3744
3810
|
}
|
|
3745
3811
|
rows = kept;
|
|
3746
3812
|
}
|
|
3813
|
+
// The folder chain was lifted off the path into X-Drafted-Folder — but this
|
|
3814
|
+
// listing is fetched with NO headers and was never filtered by it, so
|
|
3815
|
+
// ls /o/<org>/Marketing/projects answered with every project in the org: a
|
|
3816
|
+
// listing that silently ignores half the address it was given. Scope to
|
|
3817
|
+
// that folder and below, matching the org root, which lists the projects
|
|
3818
|
+
// in its folders too.
|
|
3819
|
+
if (folder) {
|
|
3820
|
+
const want = normalizeFolder(folder);
|
|
3821
|
+
rows = rows.filter(x => {
|
|
3822
|
+
const f = normalizeFolder(x.folder || '');
|
|
3823
|
+
return f === want || f.startsWith(want + '/');
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3747
3826
|
// The bound project is read from THIS session's state, never the shared
|
|
3748
3827
|
// active-project row (DRAFT-36 concurrency invariant).
|
|
3749
3828
|
const bound = getState().projectMeta;
|
|
@@ -3795,28 +3874,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3795
3874
|
}
|
|
3796
3875
|
return ok(out.join('\n'));
|
|
3797
3876
|
}
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
}
|
|
3814
|
-
} else if (parts.length === 2) {
|
|
3815
|
-
projectRef = parts[0]; // /projects/<project>/<layer>
|
|
3816
|
-
layer = parts[1]; lane = null; filename = null;
|
|
3817
|
-
} else if (parts.length === 1) {
|
|
3818
|
-
projectRef = parts[0]; // /projects/<project>
|
|
3877
|
+
// Where the FOLDER chain ends and the project begins cannot be decided from
|
|
3878
|
+
// the string: /projects/Marketing/beoflow-marketing (folder + project) and
|
|
3879
|
+
// /projects/some-project/designs (project + layer) are the SAME SHAPE. The
|
|
3880
|
+
// parse used to guess positionally — parts[0], or parts[1] once there were
|
|
3881
|
+
// five segments — so the folder-qualified path fs(ls) ITSELF PRINTS came back
|
|
3882
|
+
// `project not found: Marketing`: a listing whose output is not an address
|
|
3883
|
+
// you can feed back in, which is the one thing a path must always be.
|
|
3884
|
+
// Settle it the way the rest of fs settles ambiguity: by what exists. The
|
|
3885
|
+
// bare form (folder omitted) is tried FIRST, so every path that worked
|
|
3886
|
+
// before still resolves in one lookup and nothing pays for this.
|
|
3887
|
+
projectRef = parts[0];
|
|
3888
|
+
let tail = parts.slice(1);
|
|
3889
|
+
let pathProject = await resolveProjectRef(projectRef).catch(() => null);
|
|
3890
|
+
if (!pathProject?.id && parts.length > 1) {
|
|
3891
|
+
const found = await resolveFolderQualifiedProject(parts);
|
|
3892
|
+
if (found) { projectRef = parts[found.index]; tail = parts.slice(found.index + 1); pathProject = found.meta; }
|
|
3819
3893
|
}
|
|
3894
|
+
({ layer, lane, filename } = splitProjectTail(tail));
|
|
3820
3895
|
|
|
3821
3896
|
// fs grammar: the project comes from the PATH, not the shared session
|
|
3822
3897
|
// binding — scope the whole call to the path's project (request-local, per
|
|
@@ -3825,17 +3900,42 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3825
3900
|
// parallel agent can rewrite), so ls/read/write silently touched the wrong
|
|
3826
3901
|
// project and "fixed itself" when something re-bound — the under-reporting
|
|
3827
3902
|
// churn reported from the MJ Directive org.
|
|
3828
|
-
if (
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
//
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3903
|
+
if (!pathProject?.id) {
|
|
3904
|
+
// Name the whole path, not parts[0] — "project not found: Marketing" on
|
|
3905
|
+
// /projects/Marketing/beoflow-marketing is the message that sent an agent
|
|
3906
|
+
// hunting for a project called Marketing.
|
|
3907
|
+
return err(new Error(`project not found: ${parts.join('/')} — list projects with fs(ls, path="/o/<org>/projects")`));
|
|
3908
|
+
}
|
|
3909
|
+
// An org-scoped path must name a project of that org — never a same-named
|
|
3910
|
+
// project from another org (the path is both address and guardrail).
|
|
3911
|
+
if (orgFromPath && !(await pathOrgMatches(orgFromPath, pathProject))) {
|
|
3912
|
+
return err(new Error(`project ${projectRef} is not in org ${orgFromPath}`));
|
|
3838
3913
|
}
|
|
3914
|
+
getState().projectId = pathProject.id;
|
|
3915
|
+
getState().projectMeta = pathProject;
|
|
3916
|
+
|
|
3917
|
+
// The DESTINATION side of the same grammar, folder chain and all. mv and link
|
|
3918
|
+
// share it so the two cannot drift on what an address means — and because the
|
|
3919
|
+
// destination had the mirror of the source bug plus a worse failure mode: a
|
|
3920
|
+
// folder-qualified `to` resolved "Marketing" as the project, found nothing,
|
|
3921
|
+
// and then mv fell through to an IN-PROJECT move to "/site/designs". A
|
|
3922
|
+
// cross-project move that silently stays put is the exact quiet degradation
|
|
3923
|
+
// this codebase keeps paying for, so an unresolvable destination now throws.
|
|
3924
|
+
// Returns null when `to` is a bare in-project path (no /projects prefix).
|
|
3925
|
+
const resolveProjectsDestination = async (raw) => {
|
|
3926
|
+
const clean = String(raw || '').replace(/^\/+|\/+$/g, '');
|
|
3927
|
+
if (!clean.startsWith('projects/') && clean !== 'projects') return null;
|
|
3928
|
+
const segs = clean.replace(/^projects\/?/, '').split('/').filter(Boolean);
|
|
3929
|
+
if (!segs.length) throw new Error('destination needs a project: /o/<org>/projects/<project>/...');
|
|
3930
|
+
let idx = 0;
|
|
3931
|
+
let meta = await resolveProjectRef(segs[0]).catch(() => null);
|
|
3932
|
+
if (!meta?.id && segs.length > 1) {
|
|
3933
|
+
const found = await resolveFolderQualifiedProject(segs);
|
|
3934
|
+
if (found) { idx = found.index; meta = found.meta; }
|
|
3935
|
+
}
|
|
3936
|
+
if (!meta?.id) throw new Error(`destination project not found: ${segs.join('/')} — list projects with fs(ls, path="/o/<org>/projects")`);
|
|
3937
|
+
return { meta, rest: segs.slice(idx + 1) };
|
|
3938
|
+
};
|
|
3839
3939
|
|
|
3840
3940
|
const run = async () => {
|
|
3841
3941
|
const hasFile = layer && filename;
|
|
@@ -3851,15 +3951,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3851
3951
|
|
|
3852
3952
|
switch (action) {
|
|
3853
3953
|
case 'ls': {
|
|
3854
|
-
// Listing path relative to the project
|
|
3855
|
-
//
|
|
3954
|
+
// Listing path relative to the project — everything after the project
|
|
3955
|
+
// segment, whatever the folder chain in front of it was. A lane/layer
|
|
3856
3956
|
// URL IS this path (Q2), so ls of a shared lane URL lists the lane.
|
|
3857
|
-
// (
|
|
3858
|
-
//
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
else if (projectRef && parts.length === 2) { lsProjectId = projectRef; lsPath = '/' + parts[1]; }
|
|
3862
|
-
else { lsProjectId = projectRef; lsPath = '/'; }
|
|
3957
|
+
// (Taken from `tail`, not sliced off `parts` by position: with a folder
|
|
3958
|
+
// in the path the positional slice took the wrong two segments, which
|
|
3959
|
+
// is the other half of why folder-form ls was a dead end.)
|
|
3960
|
+
const lsPath = tail.length ? '/' + tail.join('/') : '/';
|
|
3863
3961
|
const lsParams = new URLSearchParams({ path: lsPath });
|
|
3864
3962
|
if (recursive) { lsParams.set('recursive', 'true'); lsParams.set('summary', 'true'); }
|
|
3865
3963
|
if (pattern) lsParams.set('pattern', pattern);
|
|
@@ -3950,25 +4048,53 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3950
4048
|
return ok(result);
|
|
3951
4049
|
}
|
|
3952
4050
|
case 'mv': {
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
4051
|
+
// Same three scopes as rm — a file, a container (lane or layer), or the
|
|
4052
|
+
// project itself. mv used to assume a FILE at every scope: a lane path
|
|
4053
|
+
// built `/screens/main/null` and 404'd, and a project path built
|
|
4054
|
+
// `/null/null`, so "rename a project" and "move a lane" both read as
|
|
4055
|
+
// capabilities Drafted does not have. It has both; only this seam was
|
|
4056
|
+
// missing. (moveFrames has carried units 'lane' and 'layer', with
|
|
4057
|
+
// connectors and assets, since cross-project move shipped.)
|
|
4058
|
+
const scope = rmScope(layer, lane, filename);
|
|
4059
|
+
if (scope === 'project') {
|
|
4060
|
+
const pid = getState().projectId;
|
|
4061
|
+
if (!pid) return err(new Error('could not resolve project id for rename'));
|
|
4062
|
+
// `to` may be a bare new name or a full destination path — the last
|
|
4063
|
+
// segment is the name either way.
|
|
4064
|
+
const newName = String(to || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).pop();
|
|
4065
|
+
if (!newName) return err(new Error('mv on a project path renames it — pass the new name, e.g. to="Onboarding — source of truth"'));
|
|
4066
|
+
const renamed = await api('PATCH', `/api/project/${pid}`, { name: newName }, orgHeader);
|
|
4067
|
+
const meta = getState().projectMeta || {};
|
|
4068
|
+
return ok({
|
|
4069
|
+
renamed: true, projectId: pid, name: renamed?.name || newName, slug: meta.slug,
|
|
4070
|
+
path: `/o/${meta.orgSlug || orgFromPath || ''}/projects/${[meta.folder, meta.slug || pid].filter(Boolean).join('/')}`,
|
|
4071
|
+
// Say this before the agent hands a human a URL it inferred from the
|
|
4072
|
+
// new name. Renaming is a DISPLAY-name change, exactly as in the web
|
|
4073
|
+
// UI: the slug is left alone so every link already shared keeps working.
|
|
4074
|
+
slugNote: 'Slug and URLs are UNCHANGED — rename sets the display name only, so links already shared keep working. Keep addressing the project by the slug above.',
|
|
4075
|
+
});
|
|
4076
|
+
}
|
|
4077
|
+
// Container source: a lane (/{layer}/{lane}) or a whole layer
|
|
4078
|
+
// (/{layer}). The destination is a container path in the same grammar.
|
|
4079
|
+
const from = scope === 'directory'
|
|
4080
|
+
? `/${layer}${lane ? '/' + lane : ''}`
|
|
4081
|
+
: (lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`);
|
|
4082
|
+
let dest;
|
|
4083
|
+
try { dest = await resolveProjectsDestination(to); }
|
|
4084
|
+
catch (e) { return err(e); }
|
|
3957
4085
|
let toPath, toProjectId;
|
|
3958
|
-
if (
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
// the path; resolve toProjectId only when it differs from the source.
|
|
3962
|
-
const toRef = toParts[0];
|
|
3963
|
-
const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
|
|
3964
|
-
if (!sameProject) {
|
|
3965
|
-
const toMeta = await resolveProjectRef(toRef).catch(() => null);
|
|
3966
|
-
if (toMeta) toProjectId = toMeta.id;
|
|
4086
|
+
if (dest) {
|
|
4087
|
+
if (!dest.rest.length) {
|
|
4088
|
+
return err(new Error('mv needs a destination layer: /o/<org>/projects/<project>/<layer>[/<lane>][/<file>]'));
|
|
3967
4089
|
}
|
|
3968
|
-
|
|
4090
|
+
// Compare RESOLVED ids, not the strings: the same project addressed
|
|
4091
|
+
// two ways (slug here, folder-qualified path there) is one project.
|
|
4092
|
+
if (dest.meta.id !== getState().projectId) toProjectId = dest.meta.id;
|
|
4093
|
+
toPath = '/' + dest.rest.join('/');
|
|
3969
4094
|
} else {
|
|
3970
4095
|
// Bare relative path (no project prefix): /{layer}[/{lane}]/{file}
|
|
3971
|
-
|
|
4096
|
+
const segs = String(to || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
4097
|
+
toPath = segs.length ? '/' + segs.join('/') : String(to || '');
|
|
3972
4098
|
}
|
|
3973
4099
|
const result = await api('POST', '/api/fs/mv', { from, to: toPath, ...(toProjectId ? { toProjectId } : {}) }, orgHeader);
|
|
3974
4100
|
return ok(result);
|
|
@@ -4008,28 +4134,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4008
4134
|
if (args.url) {
|
|
4009
4135
|
body.url = args.url;
|
|
4010
4136
|
} else if (to) {
|
|
4011
|
-
// Same destination grammar as mv
|
|
4012
|
-
// frame in another project,
|
|
4013
|
-
// itself (the "this task belongs
|
|
4014
|
-
// /<layer>/... is a frame in this
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4137
|
+
// Same destination grammar as mv, through the SAME resolver: a
|
|
4138
|
+
// /projects/<...>/<layer>/... path is a frame in another project,
|
|
4139
|
+
// /projects/<...> alone is the project itself (the "this task belongs
|
|
4140
|
+
// to that project" edge), and a bare /<layer>/... is a frame in this
|
|
4141
|
+
// one. Sharing the resolver is the point — a link and a move that
|
|
4142
|
+
// disagreed about which project a path names would be silent.
|
|
4143
|
+
let dest;
|
|
4144
|
+
try { dest = await resolveProjectsDestination(to); }
|
|
4145
|
+
catch (e) { return err(e); }
|
|
4146
|
+
if (dest && !dest.rest.length) {
|
|
4020
4147
|
body.toType = 'project';
|
|
4021
|
-
body.toId =
|
|
4022
|
-
} else if (
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
if (!sameProject) {
|
|
4026
|
-
const toMeta = await resolveProjectRef(toRef).catch(() => null);
|
|
4027
|
-
if (!toMeta?.id) return err(new Error(`project not found: ${toRef}`));
|
|
4028
|
-
body.toProjectId = toMeta.id;
|
|
4029
|
-
}
|
|
4030
|
-
body.to = '/' + toParts.slice(1).join('/');
|
|
4148
|
+
body.toId = dest.meta.id;
|
|
4149
|
+
} else if (dest) {
|
|
4150
|
+
if (dest.meta.id !== getState().projectId) body.toProjectId = dest.meta.id;
|
|
4151
|
+
body.to = '/' + dest.rest.join('/');
|
|
4031
4152
|
} else {
|
|
4032
|
-
|
|
4153
|
+
const segs = String(to).replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
4154
|
+
body.to = segs.length ? '/' + segs.join('/') : String(to);
|
|
4033
4155
|
}
|
|
4034
4156
|
} else {
|
|
4035
4157
|
return err(new Error(`${action} needs a target: to="<frame or project path>" or url="https://..."`));
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { mkdtempSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin, rmScope } from './server.mjs';
|
|
8
|
+
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin, rmScope, splitProjectTail } from './server.mjs';
|
|
9
9
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
10
10
|
|
|
11
11
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
@@ -178,6 +178,30 @@ assert.equal(rmScope('slides', 'deck', null), 'directory', 'a LANE path must nev
|
|
|
178
178
|
assert.equal(rmScope('slides', null, null), 'directory', 'a LAYER path must never archive the project');
|
|
179
179
|
assert.equal(rmScope(null, null, null), 'project', 'only the bare project path archives the project');
|
|
180
180
|
|
|
181
|
+
// fs(mv) reads the SAME three scopes, which is why they share one predicate: a
|
|
182
|
+
// file path moves one frame, a lane/layer path moves the whole set, and a bare
|
|
183
|
+
// project path renames the project. mv used to assume 'file' at every scope and
|
|
184
|
+
// built "/slides/deck/null" for a lane — a 404 that read as "lanes cannot move".
|
|
185
|
+
assert.equal(rmScope('slides', 'deck', null), 'directory', 'mv on a LANE path moves the lane, not a frame called null');
|
|
186
|
+
assert.equal(rmScope('slides', null, null), 'directory', 'mv on a LAYER path moves the layer');
|
|
187
|
+
assert.equal(rmScope(null, null, null), 'project', 'mv on a bare project path renames the project');
|
|
188
|
+
|
|
189
|
+
// splitProjectTail: everything AFTER the project segment. It reads the tail only,
|
|
190
|
+
// so the folder chain in front of the project cannot shift the layer/lane/file
|
|
191
|
+
// the way the old positional `parts.length ===` branches did — that is what made
|
|
192
|
+
// /projects/Marketing/beoflow-marketing answer "project not found: Marketing".
|
|
193
|
+
{
|
|
194
|
+
const t = (tail, expected, why) => assert.deepEqual(splitProjectTail(tail), expected, why);
|
|
195
|
+
t([], { layer: undefined, lane: null, filename: null }, 'no tail = the project itself');
|
|
196
|
+
t(['designs'], { layer: 'designs', lane: null, filename: null }, 'one segment = a layer');
|
|
197
|
+
t(['designs', 'default'], { layer: 'designs', lane: 'default', filename: null }, 'no extension = a lane');
|
|
198
|
+
t(['designs', 'a.html'], { layer: 'designs', lane: null, filename: 'a.html' }, 'an extension = a layer-root file');
|
|
199
|
+
t(['designs', 'default', 'a.html'], { layer: 'designs', lane: 'default', filename: 'a.html' }, 'the full frame path');
|
|
200
|
+
// A lane may legitimately be named after a layer; treating it as a file here
|
|
201
|
+
// would send the read to /designs/designs and 404 on a lane that exists.
|
|
202
|
+
t(['plans', 'designs'], { layer: 'plans', lane: 'designs', filename: null }, 'a layer NAME as the last segment is still a lane');
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
console.log('org-guard policy OK');
|
|
182
206
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
183
207
|
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.36",
|
|
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": [
|