drafted 1.19.26 → 1.19.28

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
@@ -230,6 +230,27 @@ export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activePro
230
230
  return (orgCount || 0) > 1;
231
231
  }
232
232
 
233
+ // SEARCH IS STRICTER THAN A MUTATION, and deliberately so.
234
+ //
235
+ // A mutation credits a bound/active project as an addressing root, because the
236
+ // destination is echoed back in the receipt: a surprising org is VISIBLE after
237
+ // the fact. A search has no such tell. Scoping it from a binding the agent did
238
+ // not state produces an empty result set indistinguishable from "this does not
239
+ // exist anywhere" — the agent stops looking, and the thing it wanted was one org
240
+ // away the whole time. A silent false negative is worse than an error, because
241
+ // nothing about it announces itself.
242
+ //
243
+ // So a search must NAME where it is looking. Only two things satisfy that: an
244
+ // org in the path (/o/<org>/...) or an explicit org= on the call. A binding does
245
+ // not, because the point is that the CALLER declared the scope.
246
+ //
247
+ // Single-org users are exempt: with one org there is nothing to be fenced off
248
+ // FROM, so the answer cannot be misleading and the friction buys nothing.
249
+ export function searchNeedsOrg({ explicitOrg, orgFromPath, orgCount }) {
250
+ if (explicitOrg || orgFromPath) return false;
251
+ return (orgCount || 0) > 1;
252
+ }
253
+
233
254
  // Did the server reject the working org THIS session injected as X-Drafted-Org?
234
255
  // A working org the caller isn't a member of can only be stale or foreign — state
235
256
  // rehydrated from another server's database, or the user removed from the org —
@@ -2106,6 +2127,51 @@ function desktopAppBinary() {
2106
2127
  return null; // e.g. Linux — no desktop app → device-code fallback
2107
2128
  }
2108
2129
 
2130
+ /**
2131
+ * The answer to "am I signed in?", which is a question about the MACHINE, not this
2132
+ * process. Two things went wrong before this existed, and they are the same mistake:
2133
+ *
2134
+ * - ~/.drafted/auth.json could be missing while the in-process session was fine. The
2135
+ * desktop's credential burn deletes it on purpose, so `login` answered
2136
+ * already_authenticated — truthfully, and uselessly — while every other reader on the
2137
+ * machine stayed signed out. Restore it from the live session.
2138
+ * - The DESKTOP app could be signed out, which is what actually decides whether an agent
2139
+ * asking for a human reaches anybody: its focus listener needs desktop.json. An agent
2140
+ * must never MINT that credential (origin='browser' is the class that may approve
2141
+ * actions), but opening the app's sign-in window is this tool's documented job — and it
2142
+ * usually completes with NO interaction, because the shared cookie store still holds a
2143
+ * valid login.
2144
+ */
2145
+ async function alreadyAuthenticated(me, sessionId) {
2146
+ const out = { status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name };
2147
+ if (!getBootstrapSessionId()) {
2148
+ try {
2149
+ persistAuthSession({ sessionId, userId: me.userId, orgId: me.currentOrg?.id });
2150
+ out.restoredAuthFile = true;
2151
+ out.note = 'This session was signed in but ~/.drafted/auth.json was missing, so other readers on this machine (the desktop app, the CLI) were not. Rewrote it from the live session.';
2152
+ } catch { /* read-only home: report the truth rather than throwing */ }
2153
+ }
2154
+ const bin = desktopAppBinary();
2155
+ if (!bin) return out; // no app here: saying anything about it is noise
2156
+ if (!existsSync(DESKTOP_SESSION_FILE)) {
2157
+ if (await launchDesktopSignin()) {
2158
+ out.openedDesktopSignin = true;
2159
+ // Wait for the result rather than reporting an optimistic one — answering before it
2160
+ // lands is the same class of lie this whole helper exists to stop.
2161
+ for (let i = 0; i < 20 && !existsSync(DESKTOP_SESSION_FILE); i++) {
2162
+ await new Promise((r) => setTimeout(r, 500));
2163
+ }
2164
+ }
2165
+ }
2166
+ out.desktopSignedIn = existsSync(DESKTOP_SESSION_FILE);
2167
+ if (out.openedDesktopSignin) {
2168
+ out.desktopNote = out.desktopSignedIn
2169
+ ? 'The desktop app was signed out; opened its sign-in window and it completed on its own from the existing browser session.'
2170
+ : 'The desktop app is signed out and its sign-in window is open — it needs one approval there. Until then it holds no session, so an agent asking for attention cannot reach this machine.';
2171
+ }
2172
+ return out;
2173
+ }
2174
+
2109
2175
  async function launchDesktopSignin() {
2110
2176
  const bin = desktopAppBinary();
2111
2177
  if (!bin) return false;
@@ -2186,7 +2252,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted.\n\n`action=get_link` ALWAYS ret
2186
2252
  await cloneSession();
2187
2253
  connectAgentWs();
2188
2254
  }
2189
- return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
2255
+ return ok(await alreadyAuthenticated(me, existing));
2190
2256
  }
2191
2257
  } catch { /* stale session — continue to sign-in */ }
2192
2258
  }
@@ -2232,51 +2298,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted.\n\n`action=get_link` ALWAYS ret
2232
2298
  // The desktop's focus listener is one of those readers, so an agent asking
2233
2299
  // for a human reached nobody and the tool that exists to fix it reported
2234
2300
  // success.
2235
- let restoredAuthFile = false;
2236
- if (!getBootstrapSessionId()) {
2237
- try {
2238
- persistAuthSession({ sessionId: existing, userId: me.userId, orgId: me.currentOrg?.id });
2239
- restoredAuthFile = true;
2240
- } catch { /* read-only home: report the truth below rather than throwing */ }
2241
- }
2242
- // The DESKTOP's own browser credential is a separate file, and an agent
2243
- // cannot mint one — it is the origin='browser' class that may approve
2244
- // actions, which is exactly what the two-credential split protects. But
2245
- // "cannot mint it" is not "cannot ask for it": opening the app's sign-in
2246
- // window is this tool's documented job, and the window usually completes
2247
- // with no interaction at all because the shared cookie store still holds a
2248
- // valid login. Short-circuiting on the agent session skipped that entirely,
2249
- // so `login` reported success while the app stayed signed out and every
2250
- // agent that pinged the human reached nobody.
2251
- let openedDesktopSignin = false;
2252
- if (desktopAppBinary() && !existsSync(DESKTOP_SESSION_FILE)) {
2253
- openedDesktopSignin = await launchDesktopSignin();
2254
- if (openedDesktopSignin) {
2255
- // Give the app a moment to re-capture the cookie so the caller can act
2256
- // on a true answer rather than an optimistic one.
2257
- for (let i = 0; i < 20 && !existsSync(DESKTOP_SESSION_FILE); i++) {
2258
- await new Promise((r) => setTimeout(r, 500));
2259
- }
2260
- }
2261
- }
2262
- const desktopSignedIn = !desktopAppBinary() ? null : existsSync(DESKTOP_SESSION_FILE);
2263
- return ok({
2264
- status: 'already_authenticated',
2265
- userId: me.userId,
2266
- email: me.userEmail,
2267
- org: me.currentOrg?.name,
2268
- ...(restoredAuthFile ? {
2269
- restoredAuthFile: true,
2270
- note: 'This session was signed in but ~/.drafted/auth.json was missing, so other readers on this machine (the desktop app, the CLI) were not. Rewrote it from the live session.',
2271
- } : {}),
2272
- ...(desktopSignedIn === null ? {} : { desktopSignedIn }),
2273
- ...(openedDesktopSignin ? {
2274
- openedDesktopSignin: true,
2275
- desktopNote: desktopSignedIn
2276
- ? 'The desktop app was signed out; opened its sign-in window and it completed on its own from the existing browser session.'
2277
- : 'The desktop app is signed out and its sign-in window is open — it needs one approval there. Until then it holds no session, so an agent asking for attention cannot reach this machine.',
2278
- } : {}),
2279
- });
2301
+ return ok(await alreadyAuthenticated(me, existing));
2280
2302
  }
2281
2303
  } catch { /* session invalid, proceed with login */ }
2282
2304
  }
@@ -3159,12 +3181,12 @@ server.resource('info', 'drafted://info', {
3159
3181
  };
3160
3182
  });
3161
3183
 
3162
- 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="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call), `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.', {
3184
+ 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.', {
3163
3185
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3164
3186
  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)'),
3165
3187
  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'),
3166
3188
  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)'),
3167
- query: z.string().optional().describe('[search] term to match against names/content'),
3189
+ 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.'),
3168
3190
  content: z.string().optional().describe('[write] inline HTML/markdown/text'),
3169
3191
  file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
3170
3192
  base64: z.string().optional().describe('[write] base64-encoded binary content'),
@@ -3233,14 +3255,33 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3233
3255
  // three inventories. A leg that fails says so instead of reading as zero hits.
3234
3256
  if (action === 'search') {
3235
3257
  const q = String(query || '').trim();
3236
- if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/", query="<terms>")'));
3258
+ if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/o/<org>", query="<terms>")'));
3259
+ // Name the org, or don't search. See searchNeedsOrg: an unaddressed search
3260
+ // returns "no matches" from ONE org and reads as "nowhere", which is the
3261
+ // one wrong answer a search can give that nothing announces.
3262
+ {
3263
+ const orgs = await getOrgList();
3264
+ if (searchNeedsOrg({ explicitOrg: org, orgFromPath, orgCount: orgs.length })) {
3265
+ const names = orgs.map(o => o.slug || o.name || o.id).filter(Boolean);
3266
+ return err(new Error(
3267
+ `Which org? Search needs one, and you belong to ${orgs.length}: ${names.join(', ')}.\n` +
3268
+ `An unaddressed search would scope to whichever org this session inherited and report ` +
3269
+ `"no matches" for the others — a false negative you cannot see, which is worse than this error.\n` +
3270
+ `Address the org in the PATH: fs(search, path="/o/<org>", query="${q}").\n` +
3271
+ `(Or org="<name>" on this call. Searching several orgs = one call each; the results say which org they came from.)`
3272
+ ));
3273
+ }
3274
+ }
3237
3275
  const scope = orgFromPath ? `/o/${orgFromPath}` : '';
3238
3276
  const leg = async (fn) => { try { return { v: await fn() }; } catch (e) { return { e: e?.message || String(e) }; } };
3239
3277
  const [wiki, skills, projects, frames] = await Promise.all([
3240
3278
  leg(() => api('GET', `/api/wiki/search?q=${encodeURIComponent(q)}&limit=10`, undefined, orgHeader)),
3241
3279
  leg(() => api('GET', `/api/skills/search?q=${encodeURIComponent(q)}`, undefined, orgHeader)),
3242
3280
  leg(() => api('GET', '/api/projects', undefined, orgHeader)),
3243
- leg(() => withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}&limit=15`))),
3281
+ // orgHeader, so the server scopes frames instead of us filtering after the
3282
+ // fact — the row cap is applied server-side, so a client-side filter can be
3283
+ // handed a page already filled by other orgs' hits.
3284
+ leg(() => withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}&limit=15`, undefined, orgHeader))),
3244
3285
  ]);
3245
3286
  // A root search IS the prior-art search both gates ask for — it read the wiki
3246
3287
  // and the skill library. Not crediting it would send the agent back to run
@@ -3284,7 +3325,10 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3284
3325
  return hits.length ? formatFrameHits(hits, { limit: 15 }) + note : (note ? note.trimStart() : '');
3285
3326
  });
3286
3327
 
3287
- return ok(`Search "${q}"${orgFromPath ? ` in /o/${orgFromPath}` : ''}\n\n${out.join('\n\n')}`);
3328
+ const searched = orgFromPath || org || null;
3329
+ // ALWAYS say where this looked. "No matches" is only safe to act on when
3330
+ // the reader can see the scope it was computed over.
3331
+ return ok(`Search "${q}" in ${searched ? `/o/${searched}` : 'your only org'}\n\n${out.join('\n\n')}`);
3288
3332
  }
3289
3333
  if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, /o/<org>/tasks, or /o/<org>/projects'));
3290
3334
  if (orgFromPath) {
@@ -181,4 +181,30 @@ assert.equal(rmScope(null, null, null), 'project', 'only the bare project path a
181
181
  console.log('org-guard policy OK');
182
182
  // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
183
183
  // loop that keeps the event loop alive. Assertions are done — exit deterministically.
184
- process.exit(0);
184
+ // ── searchNeedsOrg: stricter than a mutation, on purpose ───────────
185
+ // A mutation credits a bound project because its receipt names the destination.
186
+ // A search has no such tell: scoping it from an unstated binding yields an empty
187
+ // result the agent reads as "does not exist anywhere". Only a NAMED org counts.
188
+ {
189
+ const { searchNeedsOrg } = await import('./server.mjs');
190
+ const t = (label, args, expected) => {
191
+ const got = searchNeedsOrg(args);
192
+ if (got !== expected) {
193
+ console.error(`FAIL searchNeedsOrg: ${label} -> ${got}, expected ${expected}`);
194
+ process.exitCode = 1;
195
+ } else console.log(`ok searchNeedsOrg: ${label}`);
196
+ };
197
+
198
+ t('multi-org, nothing named -> REFUSE', { orgCount: 3 }, true);
199
+ t('org in the path -> proceed', { orgFromPath: 'beoflow', orgCount: 3 }, false);
200
+ t('explicit org= -> proceed', { explicitOrg: 'beoflow', orgCount: 3 }, false);
201
+ t('single org -> proceed (nothing to be fenced from)', { orgCount: 1 }, false);
202
+ t('zero/unknown orgs -> proceed, never block on unknown membership', { orgCount: 0 }, false);
203
+ // The divergence from the mutation rule is the whole point: a binding does NOT
204
+ // satisfy a search, because the caller never stated the scope.
205
+ t('bound project does NOT satisfy a search', { activeProjectId: 'p1', boundOrgId: 'o1', orgCount: 2 }, true);
206
+ }
207
+
208
+ // Must exit: importing server.mjs opens the MCP WebSocket at module scope and
209
+ // pins the event loop (AGENTS.md).
210
+ process.exit(process.exitCode || 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.26",
3
+ "version": "1.19.28",
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": [