drafted 1.14.4 → 1.14.6

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
@@ -169,6 +169,22 @@ function scrubLocalPathMentions(description) {
169
169
  // inside the factory so each HTTP request gets its own isolated server.
170
170
  // Stdio mode uses the `mcpServer` singleton (built once at module load).
171
171
 
172
+ // ── Org-ambiguity policy (the one decision core) ─────────────────
173
+ // The org guard inside the factory plumbs session/HTTP state into this
174
+ // side-effect-free predicate, which IS the policy (DRAFT-36 "one rule"). Top-level
175
+ // + exported so the truth table can be asserted in isolation (mcp/test-org-guards.mjs).
176
+ // It governs BOTH creates and forks (a fork is a create): a write proceeds when its
177
+ // org is a real root — an explicit org=, a bound/active project, or a single-org
178
+ // user's only org — and refuses to GUESS only when the user is multi-org with
179
+ // nothing bound. A bound project is honored deliberately (Reading A): the active
180
+ // project is the one legitimately-kept addressing root, NOT the deleted session
181
+ // cursor. Awareness of a surprising destination (e.g. a fork) comes from the
182
+ // response receipt naming the org (orgEcho), not from hard-blocking the flow.
183
+ export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, isRemote, orgCount }) {
184
+ if (explicitOrg || boundOrgId || activeProjectId || isRemote) return false;
185
+ return (orgCount || 0) > 1;
186
+ }
187
+
172
188
  export function createMcpServer(transport) {
173
189
  // Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
174
190
  // server, not the user's machine, so local-filesystem params like `file_path`
@@ -1327,18 +1343,12 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1327
1343
  if (ctx?.id) sess.boundOrgId = ctx.id;
1328
1344
  return;
1329
1345
  }
1330
- let count = sess.cachedOrgCount;
1331
- if (count == null) {
1332
- try {
1333
- const d = await api('GET', '/api/orgs');
1334
- count = (d.orgs || d || []).length;
1335
- sess.cachedOrgCount = count;
1336
- } catch { return; } // can't determine membership — don't block a legit write
1337
- }
1338
- if (count > 1) {
1346
+ const orgs = await getOrgList();
1347
+ if (!orgs.length) return; // can't determine membership — don't block a legit write
1348
+ if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
1339
1349
  throw new Error(
1340
1350
  `Refusing to guess the org: no project is open and no explicit org was ` +
1341
- `given, but you belong to ${count} orgs — a project-less wiki/skill write ` +
1351
+ `given, but you belong to ${orgs.length} orgs — a project-less wiki/skill write ` +
1342
1352
  `would land in whichever org the session inherited. Pass org=... on this ` +
1343
1353
  `call (org name or id), or open the relevant project first ` +
1344
1354
  `(project(action="open") — the org derives from the project).`
@@ -1346,6 +1356,46 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1346
1356
  }
1347
1357
  }
1348
1358
 
1359
+ // The user's org list, cached per-session (30s TTL) — shared by the ambiguity
1360
+ // guards and the response org-echo so a mutation costs at most one /api/orgs hit.
1361
+ // Returns [] (not throw) on failure: callers treat "unknown membership" as
1362
+ // "don't block", matching the prior guard behavior.
1363
+ async function getOrgList() {
1364
+ const sess = getSessionState();
1365
+ if (sess.cachedOrgs && Date.now() - (sess.cachedOrgsTime || 0) < 30000) return sess.cachedOrgs;
1366
+ try {
1367
+ const d = await api('GET', '/api/orgs');
1368
+ sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
1369
+ sess.cachedOrgsTime = Date.now();
1370
+ } catch { sess.cachedOrgs = sess.cachedOrgs || []; }
1371
+ return sess.cachedOrgs;
1372
+ }
1373
+
1374
+
1375
+ // Build the agent-facing "receipt" for a mutation: the org it actually wrote to
1376
+ // (+ a URL handle when the result carries one), so an implicit org resolution is
1377
+ // visible in the response instead of opaque. Best-effort: returns {} on any
1378
+ // failure so it never breaks a successful write. Prefers the org id the server
1379
+ // put on the created/edited row, then the explicit org arg, then the session org.
1380
+ // This is the agent-facing half of the receipt spine (DRAFT-36 P1); the
1381
+ // human-facing card/notice (P4) consumes the same shape server-side.
1382
+ async function orgEcho(result, explicitOrg) {
1383
+ try {
1384
+ const orgs = await getOrgList();
1385
+ let orgId = result?.orgId || result?.org_id || null;
1386
+ if (!orgId && explicitOrg) {
1387
+ const hit = orgs.find(o => o.id === explicitOrg || (o.name || '').toLowerCase() === String(explicitOrg).toLowerCase());
1388
+ orgId = hit?.id || explicitOrg;
1389
+ }
1390
+ if (!orgId) { const ctx = await getCurrentOrgContext(); orgId = ctx?.id || null; }
1391
+ if (!orgId) return {};
1392
+ const receipt = { org: { id: orgId, name: orgs.find(o => o.id === orgId)?.name || null } };
1393
+ const url = result?.url || result?.frameUrl || null;
1394
+ if (url) receipt.url = url;
1395
+ return { receipt };
1396
+ } catch { return {}; }
1397
+ }
1398
+
1349
1399
  async function getOrgSkills(orgId) {
1350
1400
  if (!orgId) return [];
1351
1401
  // No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
@@ -1524,10 +1574,13 @@ async function launchDesktopSignin() {
1524
1574
  if (!bin) return false;
1525
1575
  try {
1526
1576
  const { spawn } = await import('child_process');
1527
- // If the app is already running (macOS KeepAlive normally guarantees it), the single-instance
1528
- // handler opens the sign-in window and DRAFTED_OPEN_LOGIN is ignored. If it isn't running,
1529
- // the fresh primary instance honors DRAFTED_OPEN_LOGIN=1 and opens sign-in on boot.
1530
- const child = spawn(bin, [], {
1577
+ // If the app isn't running, the fresh primary instance honors DRAFTED_OPEN_LOGIN=1 (env var)
1578
+ // and opens sign-in on boot. If it's already running (the common case — macOS KeepAlive
1579
+ // normally guarantees it), tauri-plugin-single-instance forwards this launch's ARGV to the
1580
+ // running instance but never its env, so the env var alone would be silently dropped and the
1581
+ // already-running instance would just refocus its window without opening sign-in — pass
1582
+ // --open-login as an actual arg so that path honors the request too.
1583
+ const child = spawn(bin, ['--open-login'], {
1531
1584
  detached: true,
1532
1585
  stdio: 'ignore',
1533
1586
  env: { ...process.env, DRAFTED_OPEN_LOGIN: '1' },
@@ -1909,12 +1962,15 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1909
1962
  if (g3) return err(new Error(g3));
1910
1963
  const { name, description, templateSlug, org } = args;
1911
1964
  if (!name) throw new Error('name required for action=create');
1965
+ // Don't silently create the project in whatever org the session inherited.
1966
+ await requireBoundOrgForProjectlessMutation(org);
1912
1967
  const body = { name };
1913
1968
  if (description) body.description = description;
1914
1969
  if (templateSlug) body.templateSlug = templateSlug;
1915
1970
  // `org` targets a specific org without switching the active org.
1916
1971
  const createExtra = org ? { 'X-Drafted-Org': org } : {};
1917
- return ok(withProjectBreadcrumb(await api('POST', '/api/projects', body, createExtra)));
1972
+ const created = await api('POST', '/api/projects', body, createExtra);
1973
+ return ok({ ...withProjectBreadcrumb(created), ...(await orgEcho(created, org)) });
1918
1974
  }
1919
1975
  case 'update': {
1920
1976
  const { projectId, name, folder, description, layers } = args;
@@ -2034,10 +2090,13 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
2034
2090
  case 'create': {
2035
2091
  const { name, description, layers, skillSlugs, visibility } = args;
2036
2092
  if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
2093
+ // Don't silently create the template in whatever org the session inherited.
2094
+ await requireBoundOrgForProjectlessMutation(args.org);
2037
2095
  const body = { name, description, layers };
2038
2096
  if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
2039
2097
  if (visibility) body.visibility = visibility;
2040
- return ok(await api('POST', '/api/templates', body, orgHeader));
2098
+ const createdTpl = await api('POST', '/api/templates', body, orgHeader);
2099
+ return ok({ ...createdTpl, ...(await orgEcho(createdTpl, args.org)) });
2041
2100
  }
2042
2101
  case 'update': {
2043
2102
  const { templateId, name, description, layers, skillSlugs, visibility } = args;
@@ -2049,7 +2108,8 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
2049
2108
  if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
2050
2109
  if (visibility) body.visibility = visibility;
2051
2110
  if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
2052
- return ok(await api('PUT', `/api/templates/${templateId}`, body, orgHeader));
2111
+ const updatedTpl = await api('PUT', `/api/templates/${templateId}`, body, orgHeader);
2112
+ return ok({ ...updatedTpl, ...(await orgEcho(updatedTpl, args.org)) });
2053
2113
  }
2054
2114
  case 'delete': {
2055
2115
  const { templateId } = args;
@@ -2059,9 +2119,12 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
2059
2119
  case 'fork': {
2060
2120
  const { templateId, name } = args;
2061
2121
  if (!templateId) throw new Error('templateId required for action=fork');
2122
+ // Forking creates a new template — same org rule as any create.
2123
+ await requireBoundOrgForProjectlessMutation(args.org);
2062
2124
  const body = {};
2063
2125
  if (name) body.name = name;
2064
- return ok(await api('POST', `/api/templates/${templateId}/fork`, body, orgHeader));
2126
+ const forkedTpl = await api('POST', `/api/templates/${templateId}/fork`, body, orgHeader);
2127
+ return ok({ ...forkedTpl, ...(await orgEcho(forkedTpl, args.org)) });
2065
2128
  }
2066
2129
  default:
2067
2130
  throw new Error(`Unknown template action: ${action}`);
@@ -2409,7 +2472,8 @@ async function getCachedMcpUpdateMetadata() {
2409
2472
 
2410
2473
 
2411
2474
  tool('get_org', {
2412
- action: z.enum(['get', 'update_mcp']).optional().describe('Default: "get" returns your orgs, the default org, and Google Drive availability. Use "update_mcp" to get explicit installed stdio MCP update instructions. There is no org switching: org derives from the resource you address (projectId/pageId/skillId), and creates/searches take an explicit org param.'),
2475
+ action: z.enum(['get', 'update_mcp', 'use']).optional().describe('Default: "get" returns your orgs, this session\'s resolved working org, and Google Drive availability. "use" (with org=) sets THIS session\'s working org — a per-session, per-request DEFAULT for project-less creates/forks. It is NOT the retired sticky cursor: it never overrides a UUID-addressed resource, an explicit org=, or an open project, and it is private to this session (never a shared server row). "update_mcp" returns installed stdio MCP update instructions.'),
2476
+ org: z.string().optional().describe('[use] org id or name to set as this session\'s working-org default.'),
2413
2477
  }, async (args = {}) => {
2414
2478
  try {
2415
2479
  const action = args.action || 'get';
@@ -2419,6 +2483,30 @@ tool('get_org', {
2419
2483
  return ok(buildInstalledMcpUpdateInstructions(mcpUpdate));
2420
2484
  }
2421
2485
 
2486
+ if (action === 'use') {
2487
+ // P3 (DRAFT-36): the explicit per-session working-org. Symmetric with what a remote
2488
+ // connection gets from its OAuth org — a stdio agent declares it here. Stored in
2489
+ // THIS session's bucket only (never a shared server row), injected as X-Drafted-Org
2490
+ // by api(), and only a DEFAULT: a UUID-addressed resource, an explicit org=, or an
2491
+ // open project always win. In-memory (not persisted) so it can never clobber another
2492
+ // process's boot state — the concurrency invariant holds by construction.
2493
+ const want = (args.org || '').trim();
2494
+ if (!want) throw new Error('org (id or name) required for action=use');
2495
+ const orgs = await getOrgList();
2496
+ const byId = orgs.find((o) => o.id === want);
2497
+ const byName = orgs.filter((o) => (o.name || '').toLowerCase() === want.toLowerCase());
2498
+ const hit = byId || (byName.length === 1 ? byName[0] : null);
2499
+ if (!hit) {
2500
+ const reason = byName.length > 1 ? `ambiguous org name "${want}"` : `not a member of org "${want}"`;
2501
+ throw new Error(`${reason}. Your orgs: ${orgs.map((o) => o.name || o.id).join(', ') || '(none resolvable)'}`);
2502
+ }
2503
+ getSessionState().boundOrgId = hit.id;
2504
+ return ok({
2505
+ workingOrg: { id: hit.id, name: hit.name },
2506
+ note: `Working org for this session is now "${hit.name}". Project-less creates/forks default here; a UUID-addressed resource, an explicit org=, or an open project still win. Not the retired switch — per-session and per-request, never a shared cursor.`,
2507
+ });
2508
+ }
2509
+
2422
2510
  // Source of truth = the org this MCP process scopes requests to (what mutations
2423
2511
  // will actually hit). Each MCP process is independent — multiple agents can run
2424
2512
  // in parallel scoped to different orgs. /auth/me reads sessions.org_id directly.
@@ -2429,6 +2517,13 @@ tool('get_org', {
2429
2517
  const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
2430
2518
  const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
2431
2519
 
2520
+ // This session's WORKING org (P3): where project-less creates/forks land by default.
2521
+ // It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
2522
+ // or (remote) the connection's org — not the shared session cursor. Announce it so an
2523
+ // agent can self-verify without guessing (invariant: "announced, never opaque").
2524
+ const workingOrgId = getSessionState().boundOrgId || null;
2525
+ const workingOrg = workingOrgId ? (orgs.find(o => o.id === workingOrgId) || { id: workingOrgId, name: null }) : null;
2526
+
2432
2527
  const googleDrive = await getGoogleDriveAvailability();
2433
2528
  const mcpUpdate = await getCachedMcpUpdateMetadata();
2434
2529
 
@@ -2441,13 +2536,14 @@ tool('get_org', {
2441
2536
  }
2442
2537
  return ok({
2443
2538
  activeOrg,
2539
+ workingOrg,
2444
2540
  orgs,
2445
2541
  members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
2446
2542
  googleDrive,
2447
2543
  mcpVersion: PACKAGE_VERSION,
2448
2544
  mcpUpdate,
2449
2545
  session: await sessionSurfaceBlock(),
2450
- note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive. activeOrg is only the DEFAULT for calls that name no resource and pass no org param to target a different org, pass org=... on the call (wiki write/search, skill add/update/fork/push/list, project create, template actions), never a switch. `session` is THIS agent's own surface identity — `session.name` is the human-readable tab name the user sees (use it to identify which agent you are); refresh it via whoami. Concurrent MCP sessions can operate on different orgs simultaneously. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
2546
+ note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive. `workingOrg` is where project-less creates/forks land by default set it explicitly with get_org(action=\"use\", org=...) when you're multi-org and working project-less; a UUID/explicit org=/open project always wins. It is per-session and per-request, never a shared cursor or a switch. `session` is THIS agent's own surface identity — `session.name` is the human-readable tab name the user sees (use it to identify which agent you are); refresh it via whoami. Concurrent MCP sessions can operate on different orgs simultaneously. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
2451
2547
  });
2452
2548
  } catch (error) { return err(error); }
2453
2549
  });
@@ -3397,7 +3493,9 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3397
3493
  // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
3398
3494
  // UUID-first exception: update/remove addressed by a skillId derive their org
3399
3495
  // from the skill row server-side, so the bound-org gate is unnecessary.
3400
- if (['add', 'update', 'remove', 'push', 'fork', 'import'].includes(action)) {
3496
+ // `fork` (and the fork-on-403 branch inside `update`) run the same guard at
3497
+ // fork time, inside their cases — a fork is a create, governed by the one rule.
3498
+ if (['add', 'update', 'remove', 'push', 'import'].includes(action)) {
3401
3499
  const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
3402
3500
  if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
3403
3501
  }
@@ -3475,7 +3573,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3475
3573
  // org names where the skill is born (UUID-first model: org is explicit at
3476
3574
  // create) — without this the bound-org guard accepts org= but the write
3477
3575
  // would land in the session org anyway.
3478
- return ok(await api('POST', '/api/skills', body, org ? { 'X-Drafted-Org': org } : {}));
3576
+ const addedSkill = await api('POST', '/api/skills', body, org ? { 'X-Drafted-Org': org } : {});
3577
+ return ok({ ...addedSkill, ...(await orgEcho(addedSkill, org)) });
3479
3578
  }
3480
3579
  case 'update': {
3481
3580
  // Auto-fork-on-update (tool behavior): if the target is read-only (global/
@@ -3499,9 +3598,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3499
3598
  if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
3500
3599
  try {
3501
3600
  const r = await api('PUT', `/api/skills/${id}`, body, extra);
3502
- return ok({ ...r, forked: false });
3601
+ return ok({ ...r, forked: false, ...(await orgEcho(r, org)) });
3503
3602
  } catch (e) {
3504
3603
  if (e.code !== 'skill_read_only') throw e;
3604
+ // Read-only skill → the update becomes a FORK (a create). Its org must be a
3605
+ // real root: explicit org=, the active project, or a single-org user's only
3606
+ // org — else, multi-org with nothing bound, error rather than guess. The
3607
+ // receipt names the resulting org so a surprising fork is visible (Reading A).
3608
+ await requireBoundOrgForProjectlessMutation(org);
3505
3609
  let forkId;
3506
3610
  try {
3507
3611
  forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
@@ -3511,7 +3615,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3511
3615
  forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
3512
3616
  }
3513
3617
  const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
3514
- return ok({ ...r2, forked: true });
3618
+ return ok({ ...r2, forked: true, ...(await orgEcho(r2, org)) });
3515
3619
  }
3516
3620
  }
3517
3621
  case 'remove': {
@@ -3527,8 +3631,12 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3527
3631
  let id = skillId;
3528
3632
  if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
3529
3633
  if (!id) throw new Error('skillId or skill (slug) required for action=fork');
3634
+ // Forking creates a new skill — same org rule as any create (explicit org=,
3635
+ // active project, or single-org; else refuse to guess). Receipt names the org.
3636
+ await requireBoundOrgForProjectlessMutation(org);
3530
3637
  try {
3531
- return ok(await api('POST', `/api/skills/${id}/fork`, {}, extra));
3638
+ const forkedSkill = await api('POST', `/api/skills/${id}/fork`, {}, extra);
3639
+ return ok({ ...forkedSkill, ...(await orgEcho(forkedSkill, org)) });
3532
3640
  } catch (e) {
3533
3641
  if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
3534
3642
  throw e;
@@ -3548,7 +3656,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3548
3656
  if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
3549
3657
  const pushed = await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra);
3550
3658
  if (dir) { try { if (ensureSkillInstallIgnored(dir)) pushed.gitignored = '.skillinstall/'; } catch { /* best-effort */ } }
3551
- return ok(pushed);
3659
+ return ok({ ...pushed, ...(await orgEcho(pushed, org)) });
3552
3660
  }
3553
3661
  case 'attach': {
3554
3662
  const { skillId } = args;
@@ -0,0 +1,33 @@
1
+ // Regression check for the MCP org-ambiguity policy (DRAFT-36 "one rule").
2
+ // Run: `node mcp/test-org-guards.mjs`. No framework — asserts the single pure
3
+ // decision core that the org guard delegates to, for BOTH creates and forks.
4
+ import assert from 'node:assert/strict';
5
+ import { projectlessMutationNeedsOrg } from './server.mjs';
6
+
7
+ // One rule governs create AND fork (a fork is a create). A write proceeds when its
8
+ // org is a real root — explicit org=, a bound/active project, a remote-adopted org,
9
+ // or a single-org user's only org. It refuses to GUESS only when the user is
10
+ // multi-org with nothing bound.
11
+ assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
12
+ assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
13
+ assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
14
+ assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), false, 'remote session → allow (adopts its own connection org)');
15
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
16
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
17
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
18
+
19
+ // The xcode-build incident state: multi-org + a bound project sticky from an
20
+ // earlier, unrelated project(open). Under Reading A a fork is NOT hard-blocked —
21
+ // it lands in the active project's org (a real root) and the response RECEIPT names
22
+ // that org, so the fork is visible instead of silent. Create and fork agree here by
23
+ // design (one rule); the fix for the surprise is the receipt, not a block.
24
+ const incident = { orgCount: 3, boundOrgId: 'causeway', activeProjectId: 'p1' };
25
+ assert.equal(projectlessMutationNeedsOrg(incident), false, 'incident state: fork allowed into the bound org — awareness comes from the receipt (Reading A)');
26
+
27
+ // The genuinely ambiguous case still errors for a fork, exactly as for a create:
28
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org fork with nothing bound → BLOCK before any copy is created');
29
+
30
+ console.log('org-guard policy OK');
31
+ // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
32
+ // loop that keeps the event loop alive. Assertions are done — exit deterministically.
33
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.14.4",
3
+ "version": "1.14.6",
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": [