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 +95 -51
- package/mcp/test-org-guards.mjs +27 -1
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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="/
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": [
|