drafted 1.19.35 → 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 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
- const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null };
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, cross-project), `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.', {
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'),
@@ -3758,6 +3810,19 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3758
3810
  }
3759
3811
  rows = kept;
3760
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
+ }
3761
3826
  // The bound project is read from THIS session's state, never the shared
3762
3827
  // active-project row (DRAFT-36 concurrency invariant).
3763
3828
  const bound = getState().projectMeta;
@@ -3809,28 +3874,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3809
3874
  }
3810
3875
  return ok(out.join('\n'));
3811
3876
  }
3812
- if (parts.length >= 4) {
3813
- projectRef = parts.length === 4 ? parts[0] : parts[1]; // no-folder vs folder form
3814
- layer = parts[parts.length - 3];
3815
- lane = parts[parts.length - 2];
3816
- filename = parts[parts.length - 1];
3817
- } else if (parts.length === 3) {
3818
- // /projects/<project>/<layer>/<lane> OR /projects/<project>/<layer>/<file>
3819
- // A lane view (no extension) vs a layer-root file (has an extension)
3820
- // mirror the server's dual routes.
3821
- projectRef = parts[0];
3822
- const last = parts[2];
3823
- if (/\.[a-z0-9]+$/i.test(last) && !['designs', 'research', 'plans', 'copy', 'wireframes', 'images', 'components', 'brand-assets'].includes(last)) {
3824
- layer = parts[1]; lane = null; filename = last; // layer-root file
3825
- } else {
3826
- layer = parts[1]; lane = last; filename = null; // lane view
3827
- }
3828
- } else if (parts.length === 2) {
3829
- projectRef = parts[0]; // /projects/<project>/<layer>
3830
- layer = parts[1]; lane = null; filename = null;
3831
- } else if (parts.length === 1) {
3832
- 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; }
3833
3893
  }
3894
+ ({ layer, lane, filename } = splitProjectTail(tail));
3834
3895
 
3835
3896
  // fs grammar: the project comes from the PATH, not the shared session
3836
3897
  // binding — scope the whole call to the path's project (request-local, per
@@ -3839,17 +3900,42 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3839
3900
  // parallel agent can rewrite), so ls/read/write silently touched the wrong
3840
3901
  // project and "fixed itself" when something re-bound — the under-reporting
3841
3902
  // churn reported from the MJ Directive org.
3842
- if (projectRef) {
3843
- const pathProject = await resolveProjectRef(projectRef).catch(() => null);
3844
- if (!pathProject?.id) return err(new Error(`project not found: ${projectRef}`));
3845
- // An org-scoped path must name a project of that org — never a same-named
3846
- // project from another org (the path is both address and guardrail).
3847
- if (orgFromPath && !(await pathOrgMatches(orgFromPath, pathProject))) {
3848
- return err(new Error(`project ${projectRef} is not in org ${orgFromPath}`));
3849
- }
3850
- getState().projectId = pathProject.id;
3851
- getState().projectMeta = pathProject;
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}`));
3852
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
+ };
3853
3939
 
3854
3940
  const run = async () => {
3855
3941
  const hasFile = layer && filename;
@@ -3865,15 +3951,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3865
3951
 
3866
3952
  switch (action) {
3867
3953
  case 'ls': {
3868
- // Listing path relative to the project. No-folder forms: [p] '/',
3869
- // [p, layer] '/layer', [p, layer, lane] '/layer/lane' a lane/layer
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
3870
3956
  // URL IS this path (Q2), so ls of a shared lane URL lists the lane.
3871
- // (Folder-form ls [folder, p, layer[, lane]] is a pre-existing dead
3872
- // end: the parse takes parts[0] as the project ref and resolve fails.)
3873
- let lsPath, lsProjectId;
3874
- if (projectRef && parts.length >= 3) { lsProjectId = projectRef; lsPath = '/' + parts.slice(parts.length - 2).join('/'); }
3875
- else if (projectRef && parts.length === 2) { lsProjectId = projectRef; lsPath = '/' + parts[1]; }
3876
- 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('/') : '/';
3877
3961
  const lsParams = new URLSearchParams({ path: lsPath });
3878
3962
  if (recursive) { lsParams.set('recursive', 'true'); lsParams.set('summary', 'true'); }
3879
3963
  if (pattern) lsParams.set('pattern', pattern);
@@ -3964,25 +4048,53 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3964
4048
  return ok(result);
3965
4049
  }
3966
4050
  case 'mv': {
3967
- const from = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
3968
- const toClean = (to || '').replace(/^\/+|\/+$/g, '');
3969
- const toHasProject = toClean.startsWith('projects/');
3970
- const toParts = toClean.replace(/^projects\/?/, '').split('/').filter(Boolean);
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); }
3971
4085
  let toPath, toProjectId;
3972
- if (toHasProject && toParts.length >= 2) {
3973
- // Full destination path: first segment after /projects is the project ref
3974
- // (fs grammar: /projects/<project>/<layer>[/<lane>]/<file>). Drop it from
3975
- // the path; resolve toProjectId only when it differs from the source.
3976
- const toRef = toParts[0];
3977
- const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
3978
- if (!sameProject) {
3979
- const toMeta = await resolveProjectRef(toRef).catch(() => null);
3980
- 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>]'));
3981
4089
  }
3982
- toPath = '/' + toParts.slice(1).join('/');
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('/');
3983
4094
  } else {
3984
4095
  // Bare relative path (no project prefix): /{layer}[/{lane}]/{file}
3985
- toPath = toParts.length ? '/' + toParts.join('/') : (to || '');
4096
+ const segs = String(to || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
4097
+ toPath = segs.length ? '/' + segs.join('/') : String(to || '');
3986
4098
  }
3987
4099
  const result = await api('POST', '/api/fs/mv', { from, to: toPath, ...(toProjectId ? { toProjectId } : {}) }, orgHeader);
3988
4100
  return ok(result);
@@ -4022,28 +4134,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4022
4134
  if (args.url) {
4023
4135
  body.url = args.url;
4024
4136
  } else if (to) {
4025
- // Same destination grammar as mv: /projects/<ref>/<layer>/... is a
4026
- // frame in another project, /projects/<ref> alone is the project
4027
- // itself (the "this task belongs to that project" edge), and a bare
4028
- // /<layer>/... is a frame in this one.
4029
- const toClean = String(to).replace(/^\/+|\/+$/g, '');
4030
- const toParts = toClean.replace(/^projects\/?/, '').split('/').filter(Boolean);
4031
- if (toClean.startsWith('projects/') && toParts.length === 1) {
4032
- const toMeta = await resolveProjectRef(toParts[0]).catch(() => null);
4033
- if (!toMeta?.id) return err(new Error(`project not found: ${toParts[0]}`));
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) {
4034
4147
  body.toType = 'project';
4035
- body.toId = toMeta.id;
4036
- } else if (toClean.startsWith('projects/') && toParts.length >= 2) {
4037
- const toRef = toParts[0];
4038
- const sameProject = projectRef && String(toRef).toLowerCase() === String(projectRef).toLowerCase();
4039
- if (!sameProject) {
4040
- const toMeta = await resolveProjectRef(toRef).catch(() => null);
4041
- if (!toMeta?.id) return err(new Error(`project not found: ${toRef}`));
4042
- body.toProjectId = toMeta.id;
4043
- }
4044
- 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('/');
4045
4152
  } else {
4046
- body.to = toParts.length ? '/' + toParts.join('/') : String(to);
4153
+ const segs = String(to).replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
4154
+ body.to = segs.length ? '/' + segs.join('/') : String(to);
4047
4155
  }
4048
4156
  } else {
4049
4157
  return err(new Error(`${action} needs a target: to="<frame or project path>" or url="https://..."`));
@@ -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.35",
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": [