drafted 1.7.26 → 1.8.0

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/gates.mjs ADDED
@@ -0,0 +1,124 @@
1
+ // Pure gate logic for the Drafted compounding harness.
2
+ //
3
+ // These helpers are intentionally side-effect free (no `api()`, no network, no
4
+ // module state) so they can be unit tested in isolation. mcp/server.mjs imports
5
+ // them and wires the decisions into the tool handlers + per-session getState().
6
+ //
7
+ // See docs/plans/compounding-harness.md and the Drafted "gates-checklist" frame.
8
+
9
+ // One combined per-project budget for the auto-injected priming set:
10
+ // attached-skill bodies + project anchor bodies + the active layer's rules.
11
+ // Single source of truth lives in src/shared so the server-side deposit caps
12
+ // enforce the identical limit. Re-exported here for the MCP gate helpers.
13
+ import { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget } from '../src/shared/gate-budget.mjs';
14
+ export { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget };
15
+
16
+ // ── Per-session gate flags (reset every MCP session) ──────────────────────────
17
+
18
+ export function createGateState() {
19
+ return { wikiSearched: false, skillSearched: false, templateSearched: false };
20
+ }
21
+
22
+ // kind: 'wiki' | 'skill' | 'template'
23
+ export function markSearched(gateState, kind) {
24
+ if (kind === 'wiki') gateState.wikiSearched = true;
25
+ else if (kind === 'skill') gateState.skillSearched = true;
26
+ else if (kind === 'template') gateState.templateSearched = true;
27
+ return gateState;
28
+ }
29
+
30
+ // ── Enforce gates (create chain). Return an error string if blocked, else null ─
31
+
32
+ export function g1Block(gateState, wikiIndex) {
33
+ if (gateState.wikiSearched) return null;
34
+ let msg =
35
+ 'G1: search the org wiki before reading or editing anything. ' +
36
+ 'Call wiki(action="search", query="<relevant terms>") first, then retry. ' +
37
+ 'More knowledge = less searching — start by drawing on what the org already knows.';
38
+ if (wikiIndex) msg += `\n\nWiki index (what exists to search):\n${wikiIndex}`;
39
+ return msg;
40
+ }
41
+
42
+ export function g2Block(gateState) {
43
+ if (gateState.skillSearched) return null;
44
+ return (
45
+ 'G2: search for prior-art skills before creating one. ' +
46
+ 'Call skill(action="search", query="<topic>") first — if a close match exists, improve it ' +
47
+ '(/drafted:improve-skill) instead of duplicating — then retry skill(action="add").'
48
+ );
49
+ }
50
+
51
+ export function g3Block(gateState) {
52
+ const missing = [];
53
+ if (!gateState.wikiSearched) missing.push('wiki(action="search")');
54
+ if (!gateState.skillSearched) missing.push('skill(action="search")');
55
+ if (!gateState.templateSearched) missing.push('template(action="list")');
56
+ if (missing.length === 0) return null;
57
+ return (
58
+ `G3: search before creating a project. Run ${missing.join(', ')} first ` +
59
+ '(reuse existing knowledge, skills, and templates), then retry project(action="create").'
60
+ );
61
+ }
62
+
63
+ // ── Per-project context budget (auto-inject set) ──────────────────────────────
64
+
65
+ function itemChars(it) {
66
+ if (typeof it === 'number') return it;
67
+ if (typeof it === 'string') return it.length;
68
+ if (it && typeof it === 'object') {
69
+ if (typeof it.chars === 'number') return it.chars;
70
+ if (it.content != null) return String(it.content).length;
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ export function sumChars(items) {
76
+ if (!Array.isArray(items)) return 0;
77
+ return items.reduce((total, it) => total + itemChars(it), 0);
78
+ }
79
+
80
+ export function budgetRemaining(currentTotal, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
81
+ return Math.max(0, budget - currentTotal);
82
+ }
83
+
84
+ export function wouldExceedBudget(currentTotal, addChars, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
85
+ return currentTotal + addChars > budget;
86
+ }
87
+
88
+ export function budgetError(currentTotal, addChars, label, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
89
+ const remaining = budgetRemaining(currentTotal, budget);
90
+ return (
91
+ `Per-project context budget exceeded: ${label} needs ${addChars} chars but only ${remaining} ` +
92
+ `of ${budget} remain. Tighten existing attached skills / anchors / layer rules first ` +
93
+ `(see /drafted:improve-project-harness).`
94
+ );
95
+ }
96
+
97
+ // ── G6 layer-rule default detection ───────────────────────────────────────────
98
+
99
+ // A layer prompt is "default" (skip G6 inject) when it is empty or byte-identical
100
+ // to the template's default prompt for that layer key.
101
+ export function isLayerPromptDefault(layerPrompt, templateDefaultPrompt) {
102
+ const p = (layerPrompt ?? '').trim();
103
+ if (p === '') return true;
104
+ const d = (templateDefaultPrompt ?? '').trim();
105
+ return d !== '' && p === d;
106
+ }
107
+
108
+ export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
109
+ return !isLayerPromptDefault(layerPrompt, templateDefaultPrompt);
110
+ }
111
+
112
+ // ── Wiki index formatting (bounded map injected with the G1 block) ────────────
113
+
114
+ export function formatWikiIndex(pages, max = 50) {
115
+ if (!Array.isArray(pages) || pages.length === 0) return '(wiki is empty — no pages yet)';
116
+ const lines = pages.slice(0, max).map((p) => {
117
+ const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
118
+ const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
119
+ return ` ${path}${title}`;
120
+ });
121
+ const more = pages.length > max ? `\n …and ${pages.length - max} more` : '';
122
+ return lines.join('\n') + more;
123
+ }
124
+
package/mcp/server.mjs CHANGED
@@ -20,6 +20,16 @@ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/e
20
20
  import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
22
  import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
+ import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
24
+
25
+ // Frame actions that mutate content — gated by G1 (wiki search before editing).
26
+ // Read-style actions (read, search, versions, get_*/read_*) are exempt.
27
+ const G1_MUTATING_FRAME_ACTIONS = new Set([
28
+ 'write', 'write_excalidraw', 'edit', 'mv', 'anchor', 'restore_version',
29
+ 'write_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'update_sheet',
30
+ 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc',
31
+ 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide',
32
+ ]);
23
33
  const { UMAMI_EVENTS, trackUmamiEvent } = await (async () => {
24
34
  try {
25
35
  return await import('../server/lib/umami.mjs');
@@ -76,6 +86,7 @@ function getOrCreateSessionState(sid) {
76
86
  activeProjectId: null,
77
87
  activeProjectMeta: null,
78
88
  loadedSkillIds: new Set(),
89
+ gates: createGateState(),
79
90
  cachedOrgId: null,
80
91
  cachedOrgIdTime: 0,
81
92
  wsSessionId: null,
@@ -112,6 +123,23 @@ export function runWithRequestState(initial, fn) {
112
123
  // Stripped from the advertised schema on remote transports.
113
124
  const LOCAL_ONLY_PARAMS = ['file_path'];
114
125
 
126
+ // Per-tool params whose value is arbitrary / deeply-nested JSON. Zod renders
127
+ // these (z.any(), z.array(z.any()), z.object({}).passthrough()) as an UNTYPED
128
+ // schema — an empty `{}` or an array whose `items` has no `type`. ChatGPT's
129
+ // connector import runs OpenAI's strict Structured-Outputs JSON-Schema
130
+ // validator over the whole `tools/list` and rejects the ENTIRE connector if any
131
+ // tool contains an untyped subschema (Claude tolerates it; ChatGPT does not).
132
+ // On remote transports we advertise each of these as a single JSON-encoded
133
+ // string (always a valid typed schema) and parse it back to an object in the
134
+ // tool wrapper before the handler runs — so the stdio contract, the Zod runtime
135
+ // validation, and every handler stay byte-for-byte unchanged.
136
+ const REMOTE_JSON_STRING_PARAMS = {
137
+ frame: ['excalidraw_data', 'requests'],
138
+ wiki: ['frontmatter', 'pages'],
139
+ project: ['layers'],
140
+ template: ['layers'],
141
+ };
142
+
115
143
  // Remove sentences that reference local-file params from a tool description,
116
144
  // so remote-transport descriptions don't mention options that were stripped.
117
145
  function scrubLocalPathMentions(description) {
@@ -290,6 +318,15 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
290
318
  inputSchema[k] = field.describe(scrubLocalPathMentions(field.description));
291
319
  }
292
320
  }
321
+ // Present arbitrary-JSON params as a JSON string so the advertised schema is
322
+ // ChatGPT-valid (untyped `{}` subschemas fail OpenAI's strict validator and
323
+ // fail the whole connector). The wrapper below parses them back.
324
+ for (const k of (REMOTE_JSON_STRING_PARAMS[name] || [])) {
325
+ const field = inputSchema[k];
326
+ if (!field) continue;
327
+ const orig = field.description ? ` ${field.description}` : '';
328
+ inputSchema[k] = z.string().optional().describe(`JSON-encoded value — pass a JSON string.${orig}`);
329
+ }
293
330
  description = scrubLocalPathMentions(description);
294
331
  }
295
332
  const config = {
@@ -316,6 +353,17 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
316
353
  try {
317
354
  const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
318
355
  if (requiredUpdateError) return err(new Error(requiredUpdateError));
356
+ // Reverse the remote JSON-string encoding (see REMOTE_JSON_STRING_PARAMS)
357
+ // so handlers receive the same object/array shapes they get on stdio.
358
+ if (isRemote && args?.[0] && typeof args[0] === 'object') {
359
+ for (const k of (REMOTE_JSON_STRING_PARAMS[name] || [])) {
360
+ const v = args[0][k];
361
+ if (typeof v === 'string' && v.trim()) {
362
+ try { args[0][k] = JSON.parse(v); }
363
+ catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${k === 'excalidraw_data' || k === 'frontmatter' ? 'object' : 'array'} string; JSON.parse failed.`)); }
364
+ }
365
+ }
366
+ }
319
367
  return await cb(...args);
320
368
  } finally {
321
369
  state.currentTool = previousTool;
@@ -921,94 +969,10 @@ function err(error) {
921
969
  return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
922
970
  }
923
971
 
924
- // ── Anchor enforcement ────────────────────────────────────────────
925
- // Track which frames this session has fully read (no line range = full read)
926
- const readFrameIds = new Set();
927
-
928
- // Cache anchored frames (refreshed on each check)
929
- let anchoredCache = null;
930
- let anchoredCacheTime = 0;
931
-
932
- async function getAnchoredFrames() {
933
- // Cache for 10 seconds to avoid hammering the API
934
- if (anchoredCache && Date.now() - anchoredCacheTime < 10000) return anchoredCache;
935
- try {
936
- anchoredCache = await api('GET', '/api/designs/anchored');
937
- anchoredCacheTime = Date.now();
938
- return anchoredCache;
939
- } catch {
940
- return [];
941
- }
942
- }
943
-
944
972
  function parseLayer(path) {
945
973
  return path.replace(/^\/+/, '').split('/')[0];
946
974
  }
947
975
 
948
- async function checkAnchors(layer) {
949
- const anchored = await getAnchoredFrames();
950
- if (!Array.isArray(anchored)) return null;
951
-
952
- const layerAnchors = anchored.filter(f => f.layer === layer);
953
- if (layerAnchors.length === 0) return null;
954
-
955
- const unread = layerAnchors.filter(f => !readFrameIds.has(f.id));
956
- if (unread.length === 0) return null;
957
-
958
- const paths = unread.map(f => `/${f.layer}/${f.lane}/${f.label}`);
959
- return `This layer has ${layerAnchors.length} anchored frame(s) that must be read before making changes. ` +
960
- `Unread anchors:\n${paths.map(p => ' read path="' + p + '"').join('\n')}\n\n` +
961
- `Read all anchored frames first, then retry your operation.`;
962
- }
963
-
964
- // ── Project skill enforcement ─────────────────────────────────────
965
- // Skills attached to a project must be loaded by the agent before any
966
- // mutating operation in that project. Same shape as anchor enforcement.
967
- //
968
- // Loaded-skill state is per-MCP-session (see getSessionState) so that
969
- // (a) skill.load + wiki.write across separate HTTP requests stay coherent
970
- // and (b) one user's loads never satisfy another user's gate.
971
- const projectSkillsCache = new Map(); // projectId -> { data, time }
972
-
973
- async function getProjectSkills(projectId) {
974
- if (!projectId) return [];
975
- const cached = projectSkillsCache.get(projectId);
976
- if (cached && Date.now() - cached.time < 10000) return cached.data;
977
- try {
978
- const result = await api('GET', `/api/projects/${projectId}/skills`);
979
- const skills = Array.isArray(result?.skills) ? result.skills : (Array.isArray(result) ? result : []);
980
- projectSkillsCache.set(projectId, { data: skills, time: Date.now() });
981
- return skills;
982
- } catch {
983
- return [];
984
- }
985
- }
986
-
987
- function invalidateProjectSkillsCache(projectId) {
988
- if (projectId) projectSkillsCache.delete(projectId);
989
- }
990
-
991
- async function checkProjectSkills(projectId, operation) {
992
- if (!projectId) return null;
993
- const skills = await getProjectSkills(projectId);
994
- if (skills.length === 0) return null;
995
- const loaded = getSessionState().loadedSkillIds;
996
- const unloaded = skills.filter(s => !loaded.has(s.id));
997
- if (unloaded.length === 0) return null;
998
-
999
- // Fire-and-forget: tell the server so any open browser shows a toast.
1000
- api('POST', `/api/projects/${projectId}/skill-gate-events`, {
1001
- unloadedSkills: unloaded.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
1002
- operation: operation || 'mutation',
1003
- agent: process.env.DRAFTED_AGENT_NAME || null,
1004
- }).catch(() => { /* non-fatal */ });
1005
-
1006
- const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
1007
- return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
1008
- `Unloaded skills:\n${lines.join('\n')}\n\n` +
1009
- `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
1010
- }
1011
-
1012
976
  // ── Org skill enforcement ────────────────────────────────────────
1013
977
  // Same shape as project skill enforcement but for org-attached skills.
1014
978
  // Wiki mutations require org skills to be loaded first.
@@ -1043,6 +1007,42 @@ async function getOrgSkills(orgId) {
1043
1007
  } catch { return []; }
1044
1008
  }
1045
1009
 
1010
+ // Sum the per-project priming content (attached skill bodies + project anchor
1011
+ // bodies) so deposits can be capped against the per-project context budget (G4/G5).
1012
+ async function getProjectPrimingSize(projectId) {
1013
+ if (!projectId) return 0;
1014
+ let total = 0;
1015
+ try {
1016
+ const { skills = [] } = (await api('GET', `/api/projects/${projectId}/skills`)) || {};
1017
+ for (const s of skills) {
1018
+ try { const full = await api('GET', `/api/skills/${s.id}`); total += (full?.content || '').length; } catch { /* skip */ }
1019
+ }
1020
+ } catch { /* skills unavailable */ }
1021
+ try {
1022
+ const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
1023
+ for (const a of (Array.isArray(anchored) ? anchored : [])) total += (a?.content || '').length;
1024
+ } catch { /* anchors unavailable */ }
1025
+ return total;
1026
+ }
1027
+
1028
+ // Best-effort size (chars) of a frame's content by path / URL / id — for the
1029
+ // anchor deposit cap. Returns 0 if it can't be read (cap then allows).
1030
+ async function getFrameContentSize(path) {
1031
+ try {
1032
+ const s = String(path);
1033
+ const frameId = s.match(/\/f\/([a-f0-9-]{36})/)?.[1] || (s.match(/^[a-f0-9-]{36}$/) ? s : null);
1034
+ let result;
1035
+ if (frameId) {
1036
+ result = await api('GET', `/api/fs/by-id/${frameId}`);
1037
+ } else {
1038
+ const parts = s.replace(/^\/+/, '').split('/');
1039
+ if (parts.length !== 3) return 0;
1040
+ result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
1041
+ }
1042
+ return (result?.content || '').length;
1043
+ } catch { return 0; }
1044
+ }
1045
+
1046
1046
  async function checkOrgSkills(orgId, operation) {
1047
1047
  if (!orgId) return null;
1048
1048
  const skills = await getOrgSkills(orgId);
@@ -1323,42 +1323,62 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1323
1323
  exec(`${cmd} ${JSON.stringify(url)}`);
1324
1324
  }
1325
1325
  }
1326
- let projectSkillsList = [];
1327
- try {
1328
- const skillData = await api('GET', `/api/projects/${projectId}/skills`);
1329
- projectSkillsList = skillData.skills || [];
1330
- } catch { /* skills not available yet */ }
1331
-
1332
- if (projectSkillsList.length > 0 && projectSkillsList.length <= 3) {
1326
+ // G4/G5 auto-inject (locked design): the project's attached skills + anchors
1327
+ // are pushed into the open response within the per-project context budget,
1328
+ // replacing the reject-style gate. Prefer the server-computed `priming`
1329
+ // (authoritative + fresh on deploy); fall back to MCP-side assembly for
1330
+ // older servers that don't return it.
1331
+ let responseExtras;
1332
+ const priming = result && result.priming ? result.priming : null;
1333
+ if (priming) {
1334
+ const primedSkills = priming.skills || [];
1335
+ const primedAnchors = priming.anchors || [];
1336
+ for (const s of primedSkills) getSessionState().loadedSkillIds.add(s.id);
1337
+ responseExtras = { url, opened: true, navigated, skills: primedSkills, anchors: primedAnchors };
1338
+ if (priming.budgetNotice) responseExtras.budgetNotice = priming.budgetNotice;
1339
+ } else {
1340
+ let projectSkillsList = [];
1341
+ try {
1342
+ const skillData = await api('GET', `/api/projects/${projectId}/skills`);
1343
+ projectSkillsList = skillData.skills || [];
1344
+ } catch { /* skills not available yet */ }
1333
1345
  for (const s of projectSkillsList) {
1334
1346
  try {
1335
1347
  const full = await api('GET', `/api/skills/${s.id}`);
1336
1348
  s.content = full.content;
1337
1349
  s.files = full.files || [];
1338
- // Skill content was inlined in this response count it as loaded
1339
- // so the project-skill gate doesn't immediately demand a re-load.
1340
- getSessionState().loadedSkillIds.add(s.id);
1341
- } catch { /* skip */ }
1350
+ } catch { /* leaves this skill without inlined content */ }
1342
1351
  }
1343
- }
1344
-
1345
- // Refresh project-skill cache so the gate uses fresh data after open
1346
- invalidateProjectSkillsCache(projectId);
1347
-
1348
- const responseExtras = { url, opened: true, navigated, skills: projectSkillsList };
1349
- if (projectSkillsList.length > 3) {
1350
- const sessionLoaded = getSessionState().loadedSkillIds;
1351
- const unloaded = projectSkillsList.filter(s => !sessionLoaded.has(s.id));
1352
- if (unloaded.length > 0) {
1353
- responseExtras.skillGateNotice =
1354
- `This project has ${projectSkillsList.length} attached skills (too many to auto-inline). ` +
1355
- `You MUST call skill(action="load") for each before any mutation. Unloaded: ` +
1356
- unloaded.map(s => s.slug || s.id).join(', ');
1352
+ let anchors = [];
1353
+ try {
1354
+ const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
1355
+ anchors = (Array.isArray(anchored) ? anchored : []).map(a => ({
1356
+ id: a.id,
1357
+ path: a.path || `/${a.layer || ''}/${a.lane || ''}/${a.label || ''}`,
1358
+ layer: a.layer,
1359
+ content: a.content || '',
1360
+ }));
1361
+ } catch { /* anchors unavailable */ }
1362
+ const sel = selectWithinBudget([...projectSkillsList, ...anchors], PROJECT_CONTEXT_BUDGET_CHARS);
1363
+ for (const it of sel.included) {
1364
+ if (projectSkillsList.includes(it)) getSessionState().loadedSkillIds.add(it.id);
1365
+ }
1366
+ for (const it of sel.deferred) { it.content = undefined; if ('files' in it) it.files = undefined; }
1367
+ responseExtras = { url, opened: true, navigated, skills: projectSkillsList, anchors };
1368
+ if (sel.deferred.length > 0) {
1369
+ responseExtras.budgetNotice =
1370
+ `${sel.deferred.length} attached skill(s)/anchor(s) exceeded the per-project context budget ` +
1371
+ `(${PROJECT_CONTEXT_BUDGET_CHARS} chars) and were not inlined — load explicitly if needed: ` +
1372
+ sel.deferred.map(it => it.slug || it.path || it.id).join(', ');
1357
1373
  }
1358
1374
  }
1375
+ // Don't echo the raw priming blob (surfaced via skills/anchors/budgetNotice).
1376
+ if (result && typeof result === 'object') delete result.priming;
1359
1377
  return ok({ ...result, ...responseExtras });
1360
1378
  }
1361
1379
  case 'create': {
1380
+ const g3 = g3Block(getSessionState().gates);
1381
+ if (g3) return err(new Error(g3));
1362
1382
  const { name, description, templateSlug } = args;
1363
1383
  if (!name) throw new Error('name required for action=create');
1364
1384
  const body = { name };
@@ -1400,8 +1420,11 @@ tool('template', 'Manage project templates in the org. Dispatch by `action`: lis
1400
1420
  try {
1401
1421
  const { action } = args;
1402
1422
  switch (action) {
1403
- case 'list':
1404
- return ok(await api('GET', '/api/templates'));
1423
+ case 'list': {
1424
+ const tpls = await api('GET', '/api/templates');
1425
+ markSearched(getSessionState().gates, 'template');
1426
+ return ok(tpls);
1427
+ }
1405
1428
  case 'create': {
1406
1429
  const { name, description, layers, skillSlugs, visibility } = args;
1407
1430
  if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
@@ -1536,8 +1559,6 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1536
1559
  try {
1537
1560
  const { action, projectId } = args;
1538
1561
  if (!projectId) throw new Error('projectId is required');
1539
- const skillErr = await checkProjectSkills(projectId);
1540
- if (skillErr) return err(new Error(skillErr));
1541
1562
  switch (action) {
1542
1563
  case 'add': {
1543
1564
  const { key, label, type, width, height, description, prompt } = args;
@@ -1849,11 +1870,11 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1849
1870
  bullets: z.array(z.string()).optional(),
1850
1871
  speakerNotes: z.string().optional(),
1851
1872
  layout: z.string().optional(),
1852
- }).passthrough()).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
1873
+ })).optional().describe('[write_slide_content|append_slides] Structured slide spec: [{ title, bullets, speakerNotes?, layout? }].'),
1853
1874
  requests: z.array(z.any()).optional().describe('[update_doc|update_slide] Raw Google Docs/Slides batchUpdate requests for advanced updates only; common Doc/Slide population should use write_doc_content/append_doc_content/write_slide_content/append_slides.'),
1854
1875
  slideObjectIds: z.array(z.string()).optional().describe('[clear_slides] Optional slide object IDs to delete. Omit to clear all slides.'),
1855
1876
  range: z.string().optional().describe('[Sheet value actions] A1 range, e.g. Sheet1!A1 or Data!A:Z.'),
1856
- values: z.array(z.array(z.any())).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values.'),
1877
+ values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional().describe('[write_sheet_values|append_sheet_rows] 2D array of row values (each cell: string, number, boolean, or null).'),
1857
1878
  valueInputOption: z.enum(['RAW', 'USER_ENTERED']).optional().describe('[write_sheet_values|append_sheet_rows] Google Sheets value input option. Defaults to USER_ENTERED.'),
1858
1879
  majorDimension: z.enum(['ROWS', 'COLUMNS']).optional().describe('[Sheet value actions] Major dimension for values. Defaults to ROWS when writing/appending.'),
1859
1880
  valueRenderOption: z.enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']).optional().describe('[read_sheet_values] How values should be rendered. Defaults to Google Sheets API default.'),
@@ -1884,6 +1905,16 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1884
1905
  }, async (args) => {
1885
1906
  try {
1886
1907
  const { action } = args;
1908
+ // G1: don't edit frame content before the session has searched the wiki.
1909
+ // When blocking, auto-inject the (bounded) wiki index so the search lands.
1910
+ if (G1_MUTATING_FRAME_ACTIONS.has(action)) {
1911
+ const gs = getSessionState().gates;
1912
+ if (!gs.wikiSearched) {
1913
+ let wikiIndex = '';
1914
+ try { const tree = await api('GET', '/api/wiki/tree'); wikiIndex = formatWikiIndex(tree?.pages || []); } catch { /* index best-effort */ }
1915
+ return err(new Error(g1Block(gs, wikiIndex)));
1916
+ }
1917
+ }
1887
1918
  // Echo project context on every mutation so the agent sees where the
1888
1919
  // write landed before it can develop a wrong assumption.
1889
1920
  const projectCtx = getCurrentProjectContext();
@@ -1924,9 +1955,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1924
1955
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
1925
1956
  result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
1926
1957
  }
1927
- if (!lines && result.ok && result.id) {
1928
- readFrameIds.add(result.id);
1929
- }
1930
1958
  // Surface content as the visible text. Some Claude clients prefer
1931
1959
  // structuredContent over text when both are present and structured looks
1932
1960
  // "complete" — so include content in BOTH places to ensure the agent
@@ -2058,10 +2086,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2058
2086
  const writeSources = [content != null, !!file_path, base64 != null, !!googleType].filter(Boolean).length;
2059
2087
  if (writeSources > 1) throw new Error('Provide only one of content, file_path, base64, or googleType');
2060
2088
  if (writeSources === 0) throw new Error('Provide content, file_path, base64, or googleType');
2061
- const skillErr = await checkProjectSkills(getState().projectId);
2062
- if (skillErr) return err(new Error(skillErr));
2063
- const anchorErr = await checkAnchors(parseLayer(path));
2064
- if (anchorErr) return err(new Error(anchorErr));
2065
2089
  const parts = path.replace(/^\/+/, '').split('/');
2066
2090
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
2067
2091
  if (googleType) {
@@ -2123,10 +2147,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2123
2147
  const { path, excalidraw_data, mermaid, width, height, color } = args;
2124
2148
  if (!path) throw new Error('path required for action=write_excalidraw');
2125
2149
  if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
2126
- const skillErr = await checkProjectSkills(getState().projectId);
2127
- if (skillErr) return err(new Error(skillErr));
2128
- const anchorErr = await checkAnchors(parseLayer(path));
2129
- if (anchorErr) return err(new Error(anchorErr));
2130
2150
  const parts = path.replace(/^\/+/, '').split('/');
2131
2151
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
2132
2152
  const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
@@ -2145,10 +2165,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2145
2165
  const { path, operations } = args;
2146
2166
  if (!path) throw new Error('path required for action=edit');
2147
2167
  if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
2148
- const skillErr = await checkProjectSkills(getState().projectId);
2149
- if (skillErr) return err(new Error(skillErr));
2150
- const anchorErr = await checkAnchors(parseLayer(path));
2151
- if (anchorErr) return err(new Error(anchorErr));
2152
2168
  const result = await api('POST', '/api/fs/edit', { path, operations });
2153
2169
  return ok(withProject(result), {
2154
2170
  structuredContent: frameStructuredContent(result, projectCtx),
@@ -2170,20 +2186,19 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2170
2186
  note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the rename.',
2171
2187
  }));
2172
2188
  }
2173
- const skillErr = await checkProjectSkills(getState().projectId);
2174
- if (skillErr) return err(new Error(skillErr));
2175
- const fromErr = await checkAnchors(parseLayer(from));
2176
- if (fromErr) return err(new Error(fromErr));
2177
- const toErr = await checkAnchors(parseLayer(to));
2178
- if (toErr) return err(new Error(toErr));
2179
2189
  return ok(withProject(await api('POST', '/api/fs/mv', { from, to })));
2180
2190
  }
2181
2191
  case 'anchor': {
2182
2192
  const { path, anchored } = args;
2183
2193
  if (!path) throw new Error('path required for action=anchor');
2184
2194
  if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
2185
- const skillErr = await checkProjectSkills(getState().projectId);
2186
- if (skillErr) return err(new Error(skillErr));
2195
+ // Cap (G5): anchoring adds the frame's body to the project's required-reading
2196
+ // set — reject if it would push the project past the per-project budget.
2197
+ if (anchored === true) {
2198
+ const used = await getProjectPrimingSize(getState().projectId);
2199
+ const addSize = await getFrameContentSize(path);
2200
+ if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this anchored frame')));
2201
+ }
2187
2202
  return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
2188
2203
  }
2189
2204
 
@@ -2202,8 +2217,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2202
2217
  case 'restore_version': {
2203
2218
  const { versionId, reason } = args;
2204
2219
  if (!versionId) throw new Error('versionId required for action=restore_version');
2205
- const skillErr = await checkProjectSkills(getState().projectId);
2206
- if (skillErr) return err(new Error(skillErr));
2207
2220
  const result = await api('POST', '/api/fs/restore-version', { versionId, reason });
2208
2221
  return ok(withProject(result));
2209
2222
  }
@@ -2297,10 +2310,6 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
2297
2310
  path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
2298
2311
  }, async ({ path }) => {
2299
2312
  try {
2300
- const skillErr = await checkProjectSkills(getState().projectId);
2301
- if (skillErr) return err(new Error(skillErr));
2302
- const anchorErr = await checkAnchors(parseLayer(path));
2303
- if (anchorErr) return err(new Error(anchorErr));
2304
2313
  const clean = path.replace(/^\/+|\/+$/g, '');
2305
2314
  const result = await api('DELETE', `/api/fs/${clean}`);
2306
2315
  return ok({ ...result, project: getCurrentProjectContext() });
@@ -2338,8 +2347,6 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
2338
2347
  if (op.to) layers.add(parseLayer(op.to));
2339
2348
  }
2340
2349
  for (const layer of layers) {
2341
- const anchorErr = await checkAnchors(layer);
2342
- if (anchorErr) return err(new Error(anchorErr));
2343
2350
  }
2344
2351
 
2345
2352
  // Resolve file_path → base64 for write operations before sending to server
@@ -2429,8 +2436,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
2429
2436
  const projectId = getState().projectId;
2430
2437
  if (!projectId) throw new Error('No active project. Call project(action="open") first.');
2431
2438
  if (action === 'upload') {
2432
- const skillErr = await checkProjectSkills(projectId);
2433
- if (skillErr) return err(new Error(skillErr));
2434
2439
  const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
2435
2440
  if (!asset_path) throw new Error('asset_path is required for action=upload');
2436
2441
  if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
@@ -2460,8 +2465,6 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
2460
2465
  return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
2461
2466
  }
2462
2467
  if (action === 'rm') {
2463
- const skillErr = await checkProjectSkills(projectId);
2464
- if (skillErr) return err(new Error(skillErr));
2465
2468
  const { asset_path } = args;
2466
2469
  if (!asset_path) throw new Error('asset_path is required for action=rm');
2467
2470
  if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
@@ -2487,8 +2490,6 @@ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight t
2487
2490
  layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
2488
2491
  }, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
2489
2492
  try {
2490
- const skillErr = await checkProjectSkills(getState().projectId);
2491
- if (skillErr) return err(new Error(skillErr));
2492
2493
  const body = { text, shape };
2493
2494
  if (layer) body.layer = layer;
2494
2495
  if (lane) body.lane = lane;
@@ -2513,8 +2514,6 @@ tool('group', 'Create a group (swim lane / region) on the surface. Groups are la
2513
2514
  order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
2514
2515
  }, async ({ label, color, fill, layer, lane, order }) => {
2515
2516
  try {
2516
- const skillErr = await checkProjectSkills(getState().projectId);
2517
- if (skillErr) return err(new Error(skillErr));
2518
2517
  const body = { label };
2519
2518
  if (color) body.color = color;
2520
2519
  if (fill) body.fill = fill;
@@ -2537,8 +2536,6 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
2537
2536
  connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
2538
2537
  }, async (args) => {
2539
2538
  try {
2540
- const skillErr = await checkProjectSkills(getState().projectId);
2541
- if (skillErr) return err(new Error(skillErr));
2542
2539
  const { action } = args;
2543
2540
  if (action === 'connect') {
2544
2541
  const { source, target, label, type = 'arrow-forward', color } = args;
@@ -2580,8 +2577,6 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
2580
2577
  groups: z.boolean().optional().default(false).describe('Enable group clustering. When true, shapes assigned to a group (via the group param) are clustered together, and group frames are resized to enclose their members.'),
2581
2578
  }, async ({ direction, groups }) => {
2582
2579
  try {
2583
- const skillErr = await checkProjectSkills(getState().projectId);
2584
- if (skillErr) return err(new Error(skillErr));
2585
2580
  const body = { direction };
2586
2581
  if (groups) body.groups = true;
2587
2582
  const result = await api('POST', '/api/layout', body);
@@ -2624,6 +2619,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2624
2619
  const qs = params.toString();
2625
2620
  const endpoint = query ? '/api/skills/search' : '/api/skills';
2626
2621
  const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
2622
+ markSearched(getSessionState().gates, 'skill');
2627
2623
  const cap = Math.min(Math.max(1, limit || 25), 100);
2628
2624
  if (Array.isArray(result?.skills)) {
2629
2625
  if (result.skills.length > cap) {
@@ -2679,6 +2675,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2679
2675
  return ok(await api('GET', `/api/orgs/${orgId}/skills`));
2680
2676
  }
2681
2677
  case 'add': {
2678
+ const g2 = g2Block(getSessionState().gates);
2679
+ if (g2) return err(new Error(g2));
2682
2680
  const { name, description, content, tags, triggerPatterns } = args;
2683
2681
  if (!name || !description || !content) throw new Error('name, description, content required for action=add');
2684
2682
  const body = { name, description, content };
@@ -2707,8 +2705,15 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2707
2705
  const { skillId } = args;
2708
2706
  if (!skillId) throw new Error('skillId required for action=attach');
2709
2707
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
2708
+ // Cap (G4): keep attached skills + anchors within the per-project budget so
2709
+ // the auto-inject set always fits — reject the attach if it would overflow.
2710
+ try {
2711
+ const used = await getProjectPrimingSize(getState().projectId);
2712
+ let addSize = 0;
2713
+ try { const full = await api('GET', `/api/skills/${skillId}`); addSize = (full?.content || '').length; } catch { /* unknown size */ }
2714
+ if (wouldExceedBudget(used, addSize)) return err(new Error(budgetError(used, addSize, 'this skill')));
2715
+ } catch { /* size check is best-effort */ }
2710
2716
  const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
2711
- invalidateProjectSkillsCache(getState().projectId);
2712
2717
  return ok(result);
2713
2718
  }
2714
2719
  case 'detach': {
@@ -2716,7 +2721,6 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2716
2721
  if (!skillId) throw new Error('skillId required for action=detach');
2717
2722
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
2718
2723
  const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
2719
- invalidateProjectSkillsCache(getState().projectId);
2720
2724
  return ok(result);
2721
2725
  }
2722
2726
  case 'favorite': {
@@ -2911,6 +2915,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2911
2915
  if (!searchQuery) throw new Error('query required for action=search');
2912
2916
  const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
2913
2917
  const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
2918
+ markSearched(getSessionState().gates, 'wiki');
2914
2919
  return ok({ hits });
2915
2920
  }
2916
2921
 
@@ -2989,10 +2994,6 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
2989
2994
  const { path, excalidraw_data, mermaid, width, height, color } = args;
2990
2995
  if (!path) throw new Error('path required for action=write_excalidraw');
2991
2996
  if (excalidraw_data != null && mermaid) throw new Error('Provide only one of excalidraw_data or mermaid');
2992
- const skillErr = await checkProjectSkills(getState().projectId);
2993
- if (skillErr) return err(new Error(skillErr));
2994
- const anchorErr = await checkAnchors(parseLayer(path));
2995
- if (anchorErr) return err(new Error(anchorErr));
2996
2997
  const parts = path.replace(/^\/+/, '').split('/');
2997
2998
  if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
2998
2999
  const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.7.26",
3
+ "version": "1.8.0",
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": [
@@ -28,7 +28,7 @@
28
28
  "import:legacy": "tsx scripts/import-legacy.mts",
29
29
  "test": "vitest run",
30
30
  "test:watch": "vitest",
31
- "version": "node scripts/sync-versions.mjs && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json",
31
+ "version": "node scripts/sync-versions.mjs && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json web-plugin/.claude-plugin/plugin.json web-plugin/.claude-plugin/marketplace.json",
32
32
  "version:check": "node scripts/sync-versions.mjs",
33
33
  "postpublish": "bash scripts/sync-plugin.sh \"chore: sync plugin to v$npm_package_version\"",
34
34
  "deploy:check:google": "node scripts/check-google-drive-deploy.mjs",
@@ -0,0 +1,30 @@
1
+ // Single source of truth for the per-project auto-inject context budget.
2
+ // Shared by the MCP gate logic (mcp/gates.mjs) and the server-side deposit caps
3
+ // (server/lib/project-skill-routes.mjs) so web-remote, local-stdio, and raw-API
4
+ // callers all enforce the same limit. ~24k chars ≈ 6k tokens.
5
+ export const PROJECT_CONTEXT_BUDGET_CHARS = 24000;
6
+
7
+ function gateItemChars(it) {
8
+ if (it && typeof it === 'object') {
9
+ if (it.content != null) return String(it.content).length;
10
+ if (typeof it.chars === 'number') return it.chars;
11
+ }
12
+ if (typeof it === 'string') return it.length;
13
+ if (typeof it === 'number') return it;
14
+ return 0;
15
+ }
16
+
17
+ // Greedily include items whose sizes fit the budget (in priority order); defer the
18
+ // rest. Shared by the server-side project-open priming assembly and the MCP gate
19
+ // fallback so both produce identical auto-inject sets.
20
+ export function selectWithinBudget(items, budget = PROJECT_CONTEXT_BUDGET_CHARS, sizeOf = gateItemChars) {
21
+ const included = [];
22
+ const deferred = [];
23
+ let used = 0;
24
+ for (const it of items || []) {
25
+ const size = sizeOf(it);
26
+ if (used + size <= budget) { included.push(it); used += size; }
27
+ else { deferred.push(it); }
28
+ }
29
+ return { included, deferred, used, budget };
30
+ }