drafted 1.18.0 → 1.18.2

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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Active-project persistence for the stdio MCP.
3
+ *
4
+ * The active project lives only in the in-memory sessionStates Map, so a
5
+ * `system restart mode=mcp` / skill-edit pass that re-spawns the stdio MCP
6
+ * wipes it — the recurring "active project keeps resetting, re-open it" churn.
7
+ * Persisting it lets the long-lived stdio process rehydrate transparently after
8
+ * a restart.
9
+ *
10
+ * Keyed by SERVER URL + cwd so two concurrent same-machine agent sessions (each
11
+ * its own stdio MCP, same launch config) don't clobber each other's active
12
+ * project. The server is part of the key because project and org ids are only
13
+ * meaningful within one server's DATABASE: this repo registers both a prod
14
+ * (`https://drafted.live`) and a local-dev (`http://localhost:3477`) stdio MCP,
15
+ * and with a cwd-only key the local MCP's project+boundOrgId were rehydrated by
16
+ * the prod MCP at boot — which pinned the prod session's working org to an org
17
+ * id that exists only in the local dev DB (`get_org` reported a workingOrg with
18
+ * name:null that was in no membership, and every project-less call failed with
19
+ * `not a member of org "<local-uuid>"`). Cross-database state must never share a
20
+ * key. Only the stdio process persists here; HTTP/remote user sessions are keyed
21
+ * by a real Drafted session id and never call into this store.
22
+ *
23
+ * Side-effect-free on import (no network, no eager writes) so it can be unit
24
+ * tested against the real code without booting the MCP server.
25
+ */
26
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
27
+ import { join, dirname } from 'path';
28
+ import { homedir } from 'os';
29
+
30
+ const DEFAULT_FILE = process.env.DRAFTED_MCP_STATE_FILE || join(homedir(), '.drafted', 'mcp-state.json');
31
+
32
+ function defaultCwd() {
33
+ try { return process.cwd(); } catch { return '__default__'; }
34
+ }
35
+
36
+ // serverUrl first: a URL never contains "|", so the composite key is unambiguous.
37
+ function stateKey(cwd, serverUrl) {
38
+ return serverUrl ? `${serverUrl}|${cwd}` : cwd;
39
+ }
40
+
41
+ export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
42
+ try {
43
+ const all = JSON.parse(readFileSync(file, 'utf8'));
44
+ // Pre-composite-key entries (bare cwd) are NOT read back: they carry no record of
45
+ // which server minted their ids, and adopting one is exactly the cross-DB bleed
46
+ // above. Worst case the agent re-opens its project once.
47
+ const e = all && all[stateKey(cwd, serverUrl)];
48
+ if (e && e.activeProjectId) {
49
+ return {
50
+ activeProjectId: e.activeProjectId,
51
+ activeProjectMeta: e.activeProjectMeta || null,
52
+ boundOrgId: e.boundOrgId || null,
53
+ };
54
+ }
55
+ } catch { /* no state file yet / unreadable — start fresh */ }
56
+ return null;
57
+ }
58
+
59
+ export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
60
+ try {
61
+ let all = {};
62
+ try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
63
+ const key = stateKey(cwd, serverUrl);
64
+ if (key !== cwd) delete all[cwd]; // drop the un-namespaced legacy entry for this cwd
65
+ if (entry && entry.activeProjectId) {
66
+ all[key] = {
67
+ activeProjectId: entry.activeProjectId,
68
+ activeProjectMeta: entry.activeProjectMeta || null,
69
+ boundOrgId: entry.boundOrgId || null,
70
+ };
71
+ } else {
72
+ delete all[key];
73
+ }
74
+ mkdirSync(dirname(file), { recursive: true });
75
+ writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
76
+ return true;
77
+ } catch {
78
+ // Best-effort; persistence is an optimization, never block a tool on it.
79
+ return false;
80
+ }
81
+ }
package/mcp/gates.mjs ADDED
@@ -0,0 +1,187 @@
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 fs(search, path="/wiki", 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 fs(search, path="/skills", 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('fs(search, path="/wiki")');
54
+ if (!gateState.skillSearched) missing.push('fs(search, path="/skills")');
55
+ if (!gateState.templateSearched) missing.push('fs(ls, path="/skills")');
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
+
125
+ // ── Project index formatting ──────────────────────────────────────────────────
126
+ // `ls /projects` is an ls, not a stat: one addressable line per project. The
127
+ // previous shape returned the raw /api/projects rows — ~2.4KB each once the
128
+ // per-project `layers` config (912B avg on prod) is pretty-printed — so an org
129
+ // with 180 projects blew the 90KB tool-result cap and came back truncated
130
+ // mid-JSON at 38. Names and paths are what an agent needs to pick one; layers,
131
+ // format, template and share rollups belong on `ls` of the single project.
132
+
133
+ function relAge(then, now = Date.now()) {
134
+ const t = then ? new Date(then).getTime() : NaN;
135
+ if (!Number.isFinite(t)) return '';
136
+ const d = Math.max(0, Math.floor((now - t) / 86400000));
137
+ if (d === 0) return 'today';
138
+ if (d < 7) return `${d}d`;
139
+ if (d < 60) return `${Math.floor(d / 7)}w`;
140
+ if (d < 730) return `${Math.floor(d / 30)}mo`;
141
+ return `${Math.floor(d / 365)}y`;
142
+ }
143
+
144
+ export function projectPath(p) {
145
+ const org = p.orgSlug || p.orgId || '';
146
+ const name = p.slug || p.name || p.id;
147
+ const folder = p.folder ? `${p.folder}/` : '';
148
+ return `/o/${org}/projects/${folder}${name}`;
149
+ }
150
+
151
+ /**
152
+ * One line per project, most-recently-touched first, with a header naming where
153
+ * this session is bound. `boundPath` comes from the MCP session's own state —
154
+ * never the shared active-project row (DRAFT-36: nothing another session can
155
+ * rewrite may drive what this one reports).
156
+ */
157
+ export function formatProjectIndex(projects, { max = 50, boundPath = null, now = Date.now() } = {}) {
158
+ if (!Array.isArray(projects) || projects.length === 0) {
159
+ return '(no projects — create one with fs(mkdir, path="/o/<org>/projects/<name>"))';
160
+ }
161
+ const sorted = [...projects].sort((a, b) => {
162
+ const at = new Date(a.updatedAt || a.createdAt || 0).getTime();
163
+ const bt = new Date(b.updatedAt || b.createdAt || 0).getTime();
164
+ return bt - at;
165
+ });
166
+ const shown = sorted.slice(0, max);
167
+ const paths = shown.map(projectPath);
168
+ const width = Math.min(60, Math.max(...paths.map((s) => s.length)));
169
+ const lines = shown.map((p, i) => {
170
+ const n = Number(p.frameCount);
171
+ const frames = Number.isFinite(n) && p.frameCount != null ? `${n} frame${n === 1 ? '' : 's'}` : '';
172
+ const age = relAge(p.updatedAt || p.createdAt, now);
173
+ const detail = [frames, age].filter(Boolean).join(' ');
174
+ return ` ${paths[i].padEnd(width)} ${detail}`.trimEnd();
175
+ });
176
+ const orgs = new Set(projects.map((p) => p.orgSlug || p.orgId).filter(Boolean));
177
+ const header = [
178
+ `${projects.length} project${projects.length === 1 ? '' : 's'}`,
179
+ orgs.size > 1 ? `${orgs.size} orgs` : null,
180
+ boundPath ? `bound: ${boundPath}` : null,
181
+ ].filter(Boolean).join(' · ');
182
+ const more = projects.length > max
183
+ ? `\n …and ${projects.length - max} more (recency order — narrow with pattern="<glob>" or fs(search, path="/projects", query="<terms>"))`
184
+ : '';
185
+ return `${header}\n\n${lines.join('\n')}${more}`;
186
+ }
187
+
package/mcp/server.mjs CHANGED
@@ -18,10 +18,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
18
18
  import { z } from 'zod';
19
19
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
20
20
  import WebSocket from 'ws';
21
- import { LAYERS } from '../shared/constants.mjs';
21
+ import { LAYERS } from '../src/shared/constants.mjs';
22
22
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
23
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
24
- import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
24
+ import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
25
25
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
26
26
 
27
27
  // Frame actions that mutate content — gated by G1 (wiki search before editing).
@@ -350,7 +350,7 @@ const TOOL_ANNOTATIONS = {
350
350
  minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and write a producible into the project. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled). Requires the agent allowlist.' },
351
351
  trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately, not retrievable later), list, update (enable/disable, edit template, daily limit, executor), rotate (new token), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
352
352
  fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search.' },
353
- repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), add (link a repo to a folder, --branch optional), rescan (re-fetch the tracked branch), entries (search the index). Content stays in git; Drafted keeps a read-only index.' },
353
+ repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), add (link a repo to a folder, --branch optional), rescan (re-fetch the tracked branch), entries (search the index). Content stays in git; Drafted keeps a read-only index.' },
354
354
  };
355
355
 
356
356
  function isMutatingToolCall(name, args = {}) {
@@ -1483,6 +1483,18 @@ function withProjectOverride(meta, fn) {
1483
1483
  );
1484
1484
  }
1485
1485
 
1486
+ // Run `fn` with NO project scope, so api() stops auto-appending ?projectId=<bound>.
1487
+ // Org-wide questions (searching /projects for a project that exists SOMEWHERE) must
1488
+ // not silently inherit whatever project this session happens to be bound to —
1489
+ // that scopes the answer to one project and reports "no matches" for the rest.
1490
+ function withoutProjectScope(fn) {
1491
+ const base = getState();
1492
+ return requestState.run(
1493
+ { ...base, projectId: null, projectMeta: null, _session: base._session || getSessionState() },
1494
+ fn,
1495
+ );
1496
+ }
1497
+
1486
1498
  // Tools that address frames/assets/layout inside ONE project. These accept the
1487
1499
  // per-call `projectId` override; everything else resolves org-side.
1488
1500
  const PROJECT_SCOPED_TOOLS = new Set(['fs']);
@@ -1603,11 +1615,7 @@ async function joinAgentWsRoom(projectId) {
1603
1615
  }
1604
1616
  if (!agentWs) return;
1605
1617
  const msg = JSON.stringify({ type: 'join', projectId, agent: true, agentLabel: getAgentLabel() });
1606
- if (agentWs.readyState === WebSocket.OPEN) {
1607
- agentWs.send(msg);
1608
- } else if (agentWs.readyState === WebSocket.CONNECTING) {
1609
- agentWs.once('open', () => agentWs.send(msg));
1610
- }
1618
+ sendAgentWsWhenOpen(msg);
1611
1619
  }
1612
1620
 
1613
1621
  // Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
@@ -1625,11 +1633,26 @@ async function announceSubstantiveWork() {
1625
1633
  }
1626
1634
  if (!agentWs) return;
1627
1635
  const msg = JSON.stringify({ type: 'agent-active', projectId: getState().projectId || null });
1628
- if (agentWs.readyState === WebSocket.OPEN) {
1629
- agentWs.send(msg);
1630
- } else if (agentWs.readyState === WebSocket.CONNECTING) {
1631
- agentWs.once('open', () => agentWs.send(msg));
1632
- }
1636
+ sendAgentWsWhenOpen(msg);
1637
+ }
1638
+
1639
+ // Fire-and-forget send that can NEVER crash the MCP process: captures the socket so a
1640
+ // concurrent connectAgentWs() can't swap the global mid-flight (the once('open') closure
1641
+ // used to read the global — a reassignment left it sending on a still-CONNECTING socket,
1642
+ // the uncaught throw killed the stdio child, and every subsequent MCP call was dead).
1643
+ // A dropped ping is harmless (presence/announce only); a dead MCP server is not.
1644
+ function sendAgentWsWhenOpen(msg) {
1645
+ const ws = agentWs;
1646
+ if (!ws) return;
1647
+ try {
1648
+ if (ws.readyState === WebSocket.OPEN) {
1649
+ ws.send(msg);
1650
+ } else if (ws.readyState === WebSocket.CONNECTING) {
1651
+ ws.once('open', () => {
1652
+ try { if (ws.readyState === WebSocket.OPEN) ws.send(msg); } catch { /* raced close */ }
1653
+ });
1654
+ }
1655
+ } catch { /* fire-and-forget */ }
1633
1656
  }
1634
1657
 
1635
1658
  // Clone session and connect WebSocket on startup (delayed to let server be ready).
@@ -2927,8 +2950,15 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2927
2950
  }
2928
2951
  case 'read': {
2929
2952
  if (!slug) return err(new Error('read /skills/<slug>'));
2930
- const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
2931
- return ok(s?.content || '');
2953
+ let s = null;
2954
+ try { s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader); } catch { /* not in the skills table — fall through to the repo index */ }
2955
+ if (s) return ok(s?.content || '');
2956
+ // Phase 5 resolution: any repo connected to any folder in the org is
2957
+ // readable org-wide. Content is fetched from git at read time (never
2958
+ // stored) — always fresh from the tracked branch.
2959
+ const gitSkill = await api('GET', `/api/repos/skill-content/${encodeURIComponent(slug)}`, undefined, orgHeader).catch(() => null);
2960
+ if (gitSkill?.content != null) return ok(gitSkill.content);
2961
+ return err(new Error(`skill not found: ${slug} — not in the Drafted library and not indexed from any connected repo (repo(action="entries", kind="skill") lists what is indexed)`));
2932
2962
  }
2933
2963
  case 'write': {
2934
2964
  if (!slug) return err(new Error('write /skills/<slug> requires a slug'));
@@ -2983,21 +3013,75 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2983
3013
  // Resolve the project from the path: <folder?>/<project>/<layer>/<lane>/<file>
2984
3014
  let projectRef = null, layer, lane, filename;
2985
3015
  if (parts.length === 0) {
2986
- // /projects or /o/<org>/projects — list projects (archived ones live in the
2987
- // Archive bin and are hidden from the agent's active list mirroring the web UI).
2988
- // An org-scoped root lists ONLY that org's projects (Shape A).
2989
- const projects = await api('GET', '/api/projects');
2990
- const active = Array.isArray(projects?.projects)
2991
- ? { ...projects, projects: projects.projects.filter(x => x.folder !== '__archived') }
2992
- : projects;
2993
- if (orgFromPath && Array.isArray(active?.projects)) {
3016
+ // /projects or /o/<org>/projects — the root itself. Only ls and search are
3017
+ // meaningful here; everything else needs a project in the path. This used
3018
+ // to return the listing for EVERY action, so fs(search, path="/projects")
3019
+ // silently answered a search with the full project dump.
3020
+ if (action !== 'ls' && action !== 'search') {
3021
+ return err(new Error(`fs ${action} needs a project in the path — /projects is the root (e.g. /o/<org>/projects/<project>/<layer>/<lane>/<file>). Use ls to list or search to find one.`));
3022
+ }
3023
+ // Archived projects live in the Archive bin and are hidden from the agent's
3024
+ // active list — mirroring the web UI. An org-scoped root lists ONLY that
3025
+ // org's projects (Shape A).
3026
+ const listing = await api('GET', '/api/projects');
3027
+ let rows = (Array.isArray(listing?.projects) ? listing.projects : []).filter(x => x.folder !== '__archived');
3028
+ if (orgFromPath) {
2994
3029
  const kept = [];
2995
- for (const x of active.projects) {
3030
+ for (const x of rows) {
2996
3031
  if (await pathOrgMatches(orgFromPath, x)) kept.push(x);
2997
3032
  }
2998
- active.projects = kept;
3033
+ rows = kept;
3034
+ }
3035
+ // The bound project is read from THIS session's state, never the shared
3036
+ // active-project row (DRAFT-36 concurrency invariant).
3037
+ const bound = getState().projectMeta;
3038
+ const boundPath = bound?.id ? projectPath(bound) : null;
3039
+
3040
+ if (action === 'ls') {
3041
+ if (pattern) {
3042
+ const rx = new RegExp('^' + String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
3043
+ rows = rows.filter(x => rx.test(x.slug || '') || rx.test(x.name || ''));
3044
+ }
3045
+ return ok(formatProjectIndex(rows, { boundPath }));
2999
3046
  }
3000
- return ok(active);
3047
+
3048
+ // search: "is there already a project for X?" — match project names first,
3049
+ // then frame labels across the org, so the answer is a handful of
3050
+ // addressable paths instead of the whole inventory.
3051
+ const q = String(query || '').trim();
3052
+ if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects", query="<terms>")'));
3053
+ const needle = q.toLowerCase();
3054
+ const hitProjects = rows.filter(x =>
3055
+ [x.name, x.slug, x.description].some(v => String(v || '').toLowerCase().includes(needle))
3056
+ );
3057
+ let frames = [];
3058
+ let frameError = null;
3059
+ try {
3060
+ // Unscoped: this is the org-wide "does anything for X exist?" question.
3061
+ const res = await withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}`));
3062
+ frames = Array.isArray(res) ? res : (res?.results || []);
3063
+ } catch (e) {
3064
+ // Project matches still answer "does this exist?", so don't fail the whole
3065
+ // call — but say the frame leg broke rather than implying zero hits.
3066
+ frameError = e?.message || String(e);
3067
+ }
3068
+ if (orgFromPath) {
3069
+ const allowed = new Set(rows.map(x => x.id));
3070
+ frames = frames.filter(f => allowed.has(f.projectId));
3071
+ }
3072
+ const out = [];
3073
+ out.push(hitProjects.length
3074
+ ? `Projects matching "${q}":\n${formatProjectIndex(hitProjects, { boundPath })}`
3075
+ : `No project name or description matches "${q}".`);
3076
+ if (frames.length) {
3077
+ const lines = frames.slice(0, 25).map(f =>
3078
+ ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3079
+ );
3080
+ out.push(`\nFrames matching "${q}" (${frames.length}${frames.length > 25 ? ', first 25' : ''}):\n${lines.join('\n')}`);
3081
+ } else if (frameError) {
3082
+ out.push(`\n(frame search unavailable: ${frameError} — project matches above are complete, frame matches were not checked)`);
3083
+ }
3084
+ return ok(out.join('\n'));
3001
3085
  }
3002
3086
  if (parts.length >= 4) {
3003
3087
  projectRef = parts.length === 4 ? parts[0] : parts[1]; // no-folder vs folder form
@@ -3193,8 +3277,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3193
3277
  return ok(result);
3194
3278
  }
3195
3279
  case 'search': {
3196
- const result = await api('GET', `/api/fs/search?q=${encodeURIComponent(query || '')}`, undefined, orgHeader);
3197
- return ok(result);
3280
+ // /api/fs/search has never existed — this 404'd on every call (no fs
3281
+ // route matches a single `/search` segment). /api/search is the real
3282
+ // frame-search route; scope it to the project from the path.
3283
+ const q = String(query || '').trim();
3284
+ if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects/<project>", query="<terms>")'));
3285
+ // No explicit &projectId here: run() executes inside withProjectOverride,
3286
+ // so api() already appends the path's project. Adding a second one makes
3287
+ // Express parse projectId as an ARRAY and the scope match nothing.
3288
+ const res = await api('GET', `/api/search?q=${encodeURIComponent(q)}`);
3289
+ const frames = Array.isArray(res) ? res : (res?.results || []);
3290
+ if (!frames.length) return ok(`No frames matching "${q}".`);
3291
+ const lines = frames.slice(0, 50).map(f =>
3292
+ ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3293
+ );
3294
+ return ok(`${frames.length} frame${frames.length === 1 ? '' : 's'} matching "${q}":\n${lines.join('\n')}`);
3198
3295
  }
3199
3296
  default:
3200
3297
  return err(new Error(`fs ${action} not supported for /projects`));
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Regression for textFromLocalFile() — the `file_path` text seam used by
3
+ * fs(write) on /wiki and /skills.
4
+ *
5
+ * node mcp/test-file-path-text.mjs
6
+ *
7
+ * Why this exists: /projects uploads bytes (base64 + contentType), /wiki and
8
+ * /skills take markdown text. Routing a file down the wrong seam stores
9
+ * mojibake that only surfaces when someone reads the page back, so the binary
10
+ * guard below is the load-bearing assertion, not a nicety.
11
+ */
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { textFromLocalFile } from './server.mjs';
17
+
18
+ const dir = mkdtempSync(join(tmpdir(), 'drafted-filepath-'));
19
+
20
+ // Plain markdown round-trips byte-for-byte.
21
+ const md = join(dir, 'page.md');
22
+ const body = '# Title\n\nBody with a UTF-8 em dash — and an accent é.\n';
23
+ writeFileSync(md, body, 'utf8');
24
+ assert.equal(textFromLocalFile(md), body);
25
+
26
+ // Multi-byte characters survive — a latin1 read would mangle these.
27
+ assert.ok(textFromLocalFile(md).includes('—'));
28
+ assert.ok(textFromLocalFile(md).includes('é'));
29
+
30
+ // Empty file returns '' rather than throwing. The caller decides: both write
31
+ // sites treat falsy text as "no content supplied" and error with guidance.
32
+ const empty = join(dir, 'empty.md');
33
+ writeFileSync(empty, '');
34
+ assert.equal(textFromLocalFile(empty), '');
35
+
36
+ // Binary is refused. A PNG header contains NUL bytes.
37
+ const png = join(dir, 'shot.png');
38
+ writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]));
39
+ assert.throws(() => textFromLocalFile(png), /looks binary/);
40
+
41
+ // Directories are refused with a clear message, not an EISDIR stack trace.
42
+ const sub = join(dir, 'nested');
43
+ mkdirSync(sub);
44
+ assert.throws(() => textFromLocalFile(sub), /is a directory/);
45
+
46
+ // Missing files name the path the caller passed, not the resolved one.
47
+ assert.throws(() => textFromLocalFile(join(dir, 'nope.md')), /file not found/);
48
+
49
+ // Relative paths resolve against cwd rather than being read blindly.
50
+ assert.throws(() => textFromLocalFile('definitely-not-here-xyz.md'), /file not found/);
51
+
52
+ console.log('file_path text seam ok — utf8 preserved, binary and dirs refused');
53
+
54
+ // Importing server.mjs opens the MCP WebSocket at module scope, which pins the
55
+ // event loop open forever. mcp/test-org-guards.mjs only exits because it
56
+ // finishes in ~176ms — before the socket connects — so it passes on a race
57
+ // rather than by design. Exit explicitly instead of inheriting that luck.
58
+ process.exit(0);