drafted 1.18.2 → 1.18.4

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/cli/drafted.mjs CHANGED
@@ -1679,7 +1679,7 @@ function emitSkillResult(format, obj) {
1679
1679
 
1680
1680
  skillCmd
1681
1681
  .command('add')
1682
- .description('Create a skill in Drafted from stdin JSON {name,description,content,readme,tags?,triggerPatterns?} — a README.md (readme field) is required (Phase 1 gate)')
1682
+ .description('Create a skill in Drafted from stdin JSON {name,description,content,readme?,tags?,triggerPatterns?} — readme is optional; omitting it returns the skill with a warning, not an error')
1683
1683
  .option('--format <fmt>', 'output format: json or text', 'text')
1684
1684
  .action(async (opts) => {
1685
1685
  requireLogin();
package/mcp/server.mjs CHANGED
@@ -273,6 +273,31 @@ export function stripUrlOrigin(p) {
273
273
  try { return new URL(s).pathname; } catch { return s; }
274
274
  }
275
275
 
276
+ // A skill is a DIRECTORY: /skills/<slug>/<file>. SKILL.md is the skill's content
277
+ // (the skills table row); every other path is a supporting file (README.md,
278
+ // references/, scripts/) living in skill_files. The handler used to keep only
279
+ // segment 0, so `write /skills/<slug>/README.md` silently rewrote SKILL.md — and
280
+ // the create-time README gate then looked unsatisfiable from this tool.
281
+ // Pure + exported (asserted in mcp/test-skill-paths.mjs).
282
+ // Search matches every TERM in the query, not the raw phrase. A whole-string
283
+ // substring test means a natural multi-word search ("tool surface parity MCP")
284
+ // never hits a project whose name/slug/description contains each of those words
285
+ // — while the single word "parity" does. That reads as "no such project" for a
286
+ // project that is right there. Pure + exported (asserted in mcp/test-search-terms.mjs).
287
+ export function matchesAllTerms(parts, query) {
288
+ const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
289
+ if (!terms.length) return false;
290
+ const hay = parts.map((v) => String(v || '')).join(' ').toLowerCase();
291
+ return terms.every((t) => hay.includes(t));
292
+ }
293
+
294
+ export function splitSkillPath(p) {
295
+ const rest = /^\/skills\/?$/.test(p || '') ? '' : String(p || '').replace(/^\/skills\/?/, '');
296
+ const segs = rest.split('/').filter(Boolean);
297
+ const filePath = segs.slice(1).join('/');
298
+ return { slug: segs[0] || '', filePath, isSkillMd: !filePath || filePath === 'SKILL.md' };
299
+ }
300
+
276
301
 
277
302
  export function createMcpServer(transport) {
278
303
  // Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
@@ -1543,8 +1568,11 @@ async function connectAgentWs() {
1543
1568
  // suggestedName (basename of the working directory) lets the server offer it in the
1544
1569
  // name-before-work gate message and as the tab placeholder.
1545
1570
  try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel(), suggestedName: getSuggestedSessionName() })); } catch {}
1571
+ // Same open-race guard as the hello above: the socket can leave 'open' before
1572
+ // this second send lands, and an unguarded throw here kills the stdio child
1573
+ // (taking the agent's whole MCP connection with it) for a presence message.
1546
1574
  if (getState().projectId) {
1547
- agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() }));
1575
+ try { agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() })); } catch {}
1548
1576
  }
1549
1577
  });
1550
1578
 
@@ -2759,7 +2787,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2759
2787
  ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
2760
2788
  state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
2761
2789
  metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
2762
- readme: z.string().optional().describe('[write] README.md markdown required when authoring a NEW skill at /o/<org>/skills/<slug> (the Phase 1 README gate rejects a new skill without one). Stored as a supporting skill file so the skill directory is self-documenting.'),
2790
+ readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
2763
2791
  recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
2764
2792
  lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
2765
2793
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
@@ -2933,16 +2961,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2933
2961
 
2934
2962
  // ── Root: /skills/... ──────────────────────────────────────────
2935
2963
  if (p.startsWith('/skills')) {
2936
- const slug = p === '/skills' || p === '/skills/' ? '' : p.replace(/^\/skills\/?/, '').split('/')[0];
2964
+ const { slug, filePath, isSkillMd } = splitSkillPath(p);
2965
+ const fileUrl = (id) => `/api/skills/${id}/files/${filePath.split('/').map(encodeURIComponent).join('/')}`;
2937
2966
  if (['write', 'mv', 'rm'].includes(action)) {
2938
2967
  await requireBoundOrgForProjectlessMutation(org || orgFromPath);
2939
2968
  }
2940
2969
  switch (action) {
2941
2970
  case 'ls': {
2942
- // A specific slug is a targeted ls: just that skill, not the whole root.
2971
+ // A specific slug is a targeted ls: the skill's directory SKILL.md
2972
+ // plus its supporting files, so a README is discoverable.
2943
2973
  if (slug) {
2944
2974
  const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
2945
- return ok(s ? [{ slug: s.slug, name: s.name, description: s.description }] : []);
2975
+ if (!s) return ok([]);
2976
+ const listed = await api('GET', `/api/skills/${s.id}/files`, undefined, orgHeader).catch(() => null);
2977
+ const files = (listed?.files || []).map((f) => f.path || f).filter((f) => f !== 'SKILL.md');
2978
+ return ok({ slug: s.slug, name: s.name, description: s.description, files: ['SKILL.md', ...files] });
2946
2979
  }
2947
2980
  const list = await api('GET', '/api/skills', undefined, orgHeader);
2948
2981
  const skills = Array.isArray(list) ? list : (list?.skills || []);
@@ -2950,6 +2983,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2950
2983
  }
2951
2984
  case 'read': {
2952
2985
  if (!slug) return err(new Error('read /skills/<slug>'));
2986
+ if (!isSkillMd) {
2987
+ const owner = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
2988
+ if (!owner?.id) return err(new Error(`no Drafted-authored skill "${slug}" — supporting files exist only for skills stored in Drafted; a repo-indexed skill lives in git (read its SKILL.md with fs(read, path="/skills/${slug}"))`));
2989
+ const f = await api('GET', fileUrl(owner.id), undefined, orgHeader).catch(() => null);
2990
+ if (f?.content == null) return err(new Error(`file not found in skill ${slug}: ${filePath} (fs(ls, path="/skills/${slug}") lists what is there)`));
2991
+ return ok(f.content);
2992
+ }
2953
2993
  let s = null;
2954
2994
  try { s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader); } catch { /* not in the skills table — fall through to the repo index */ }
2955
2995
  if (s) return ok(s?.content || '');
@@ -2968,6 +3008,15 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2968
3008
  }
2969
3009
  if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
2970
3010
  const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
3011
+ if (!isSkillMd) {
3012
+ // Supporting file inside an existing skill. It can't create the skill —
3013
+ // the skill row is what owns the file — so say what call does.
3014
+ if (!existing?.id) {
3015
+ return err(new Error(`skill ${slug} does not exist yet — create it first with fs(write, path="/skills/${slug}", content=<SKILL.md markdown>) (add readme=<README.md markdown> to ship a README in the same call), then write supporting files into it.`));
3016
+ }
3017
+ const written = await api('PUT', fileUrl(existing.id), { content: text }, orgHeader);
3018
+ return ok(written || { slug, path: filePath, written: true });
3019
+ }
2971
3020
  if (!existing) {
2972
3021
  const g2 = g2Block(gs);
2973
3022
  if (g2) return err(new Error(g2));
@@ -2976,9 +3025,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2976
3025
  // Derive a description from the slug when the agent didn't pass one
2977
3026
  // (the API requires a non-empty description on create).
2978
3027
  const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
2979
- const result = existing
2980
- ? await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader)
2981
- : await api('POST', '/api/skills', { slug, name, content: text, description, ...(readme ? { readme } : {}) }, orgHeader);
3028
+ let result;
3029
+ if (existing) {
3030
+ result = await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader);
3031
+ // A `readme` passed with an update is a README write, not a no-op.
3032
+ if (readme) await api('PUT', `/api/skills/${existing.id}/files/README.md`, { content: readme }, orgHeader);
3033
+ } else {
3034
+ result = await api('POST', '/api/skills', { slug, name, content: text, description, ...(readme ? { readme } : {}) }, orgHeader);
3035
+ }
2982
3036
  // Writing an archived skill restores it (update flow un-archives).
2983
3037
  if (result?.id && existing?.archived) {
2984
3038
  try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
@@ -2987,6 +3041,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2987
3041
  }
2988
3042
  case 'mv': {
2989
3043
  if (!to || !to.startsWith('/skills')) return err(new Error('mv within /skills requires to=/skills/...'));
3044
+ if (!isSkillMd) return err(new Error('mv moves a whole skill (/skills/<slug>), not a file inside one — rewrite the file at its new path and rm the old one'));
2990
3045
  const toSlug = to.replace(/^\/skills\/?/, '').split('/')[0];
2991
3046
  const result = await api('POST', '/api/skills/fork', { from: slug, to: toSlug, ...(org ? { org } : {}) }, orgHeader);
2992
3047
  return ok(result);
@@ -2994,6 +3049,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2994
3049
  case 'rm': {
2995
3050
  const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
2996
3051
  if (!s?.id) return err(new Error(`skill not found: ${slug}`));
3052
+ if (!isSkillMd) return ok(await api('DELETE', fileUrl(s.id), undefined, orgHeader));
2997
3053
  return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
2998
3054
  }
2999
3055
  case 'search': {
@@ -3050,10 +3106,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3050
3106
  // addressable paths instead of the whole inventory.
3051
3107
  const q = String(query || '').trim();
3052
3108
  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
- );
3109
+ const hitProjects = rows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q));
3057
3110
  let frames = [];
3058
3111
  let frameError = null;
3059
3112
  try {
@@ -3080,6 +3133,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3080
3133
  out.push(`\nFrames matching "${q}" (${frames.length}${frames.length > 25 ? ', first 25' : ''}):\n${lines.join('\n')}`);
3081
3134
  } else if (frameError) {
3082
3135
  out.push(`\n(frame search unavailable: ${frameError} — project matches above are complete, frame matches were not checked)`);
3136
+ } else {
3137
+ // Say the frame leg ran and found nothing. Printing only the project leg
3138
+ // leaves "frames were never searched" and "no frame matched" looking the
3139
+ // same, and frame search matches LABELS only — so a term that lives in
3140
+ // frame content is a miss, not an absence.
3141
+ out.push(`\nNo frame label matches "${q}" (frame search matches labels, not frame content).`);
3083
3142
  }
3084
3143
  return ok(out.join('\n'));
3085
3144
  }
@@ -3287,7 +3346,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3287
3346
  // Express parse projectId as an ARRAY and the scope match nothing.
3288
3347
  const res = await api('GET', `/api/search?q=${encodeURIComponent(q)}`);
3289
3348
  const frames = Array.isArray(res) ? res : (res?.results || []);
3290
- if (!frames.length) return ok(`No frames matching "${q}".`);
3349
+ if (!frames.length) return ok(`No frames matching "${q}" (frame search matches labels, not frame content).`);
3291
3350
  const lines = frames.slice(0, 50).map(f =>
3292
3351
  ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3293
3352
  );
@@ -0,0 +1,42 @@
1
+ // Regression for fs(search) term matching on /projects.
2
+ // Run: `node mcp/test-search-terms.mjs`. No framework — asserts the pure core.
3
+ //
4
+ // Why this exists: search used to substring-test the WHOLE query against each
5
+ // field, so `query="tool surface parity MCP Flow"` missed a project whose
6
+ // description says "tool surface" and "parity" — while `query="parity"` hit it.
7
+ // An agent reads that miss as "the project does not exist" and stops looking.
8
+ import assert from 'node:assert/strict';
9
+ import { matchesAllTerms } from './server.mjs';
10
+
11
+ const proj = [
12
+ 'Flow AI MCP Server Feature Card',
13
+ 'flow-ai-mcp-server-feature-card',
14
+ 'Covers the BEO-2527 parity guard for the agent tool surface.',
15
+ ];
16
+
17
+ // The reported failure: a natural multi-word query spanning name AND description.
18
+ assert.equal(matchesAllTerms(proj, 'tool surface parity MCP Flow'), true);
19
+ // The single word that did work must keep working.
20
+ assert.equal(matchesAllTerms(proj, 'parity'), true);
21
+ // Terms match across fields and across a hyphenated slug.
22
+ assert.equal(matchesAllTerms(proj, 'flow feature card'), true);
23
+ assert.equal(matchesAllTerms(proj, 'BEO-2527'), true);
24
+ // Case and surrounding whitespace are irrelevant.
25
+ assert.equal(matchesAllTerms(proj, ' PARITY Guard '), true);
26
+
27
+ // AND, not OR: one unmatched term is a miss. An OR would return most of the org.
28
+ assert.equal(matchesAllTerms(proj, 'parity kubernetes'), false);
29
+ assert.equal(matchesAllTerms(proj, 'kubernetes'), false);
30
+
31
+ // An empty query never matches — the caller rejects it before this, and
32
+ // "every term" over zero terms would otherwise be vacuously true for every row.
33
+ assert.equal(matchesAllTerms(proj, ''), false);
34
+ assert.equal(matchesAllTerms(proj, ' '), false);
35
+ assert.equal(matchesAllTerms(proj, undefined), false);
36
+
37
+ // Null/undefined fields don't throw or match a stray "null"/"undefined" term.
38
+ assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'alpha'), true);
39
+ assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'null'), false);
40
+
41
+ console.log('search term matching ok — every term must hit, across fields, never vacuously');
42
+ process.exit(0);
@@ -0,0 +1,29 @@
1
+ // Regression for the /skills path split used by fs(ls|read|write|rm).
2
+ // Run: `node mcp/test-skill-paths.mjs`. No framework — asserts the pure core.
3
+ //
4
+ // Why this exists: a skill is a directory. Before this split the handler kept
5
+ // only the first segment, so `fs(write, path="/skills/<slug>/README.md")` wrote
6
+ // SKILL.md instead — the agent's README landed on top of the skill body and the
7
+ // create-time README gate stayed unsatisfiable. isSkillMd is the load-bearing
8
+ // bit: true routes to the skills row, false routes to skill_files.
9
+ import assert from 'node:assert/strict';
10
+ import { splitSkillPath } from './server.mjs';
11
+
12
+ // Root and bare slug — no file, so the skill row itself.
13
+ assert.deepEqual(splitSkillPath('/skills'), { slug: '', filePath: '', isSkillMd: true });
14
+ assert.deepEqual(splitSkillPath('/skills/'), { slug: '', filePath: '', isSkillMd: true });
15
+ assert.deepEqual(splitSkillPath('/skills/my-skill'), { slug: 'my-skill', filePath: '', isSkillMd: true });
16
+ assert.deepEqual(splitSkillPath('/skills/my-skill/'), { slug: 'my-skill', filePath: '', isSkillMd: true });
17
+
18
+ // SKILL.md is the skill body, addressed explicitly — same destination as the bare slug.
19
+ assert.deepEqual(splitSkillPath('/skills/my-skill/SKILL.md'), { slug: 'my-skill', filePath: 'SKILL.md', isSkillMd: true });
20
+
21
+ // Everything else is a supporting file, and the subpath survives.
22
+ assert.deepEqual(splitSkillPath('/skills/my-skill/README.md'), { slug: 'my-skill', filePath: 'README.md', isSkillMd: false });
23
+ assert.deepEqual(splitSkillPath('/skills/my-skill/references/api.md'), { slug: 'my-skill', filePath: 'references/api.md', isSkillMd: false });
24
+
25
+ // Case matters: readme.md is a different file from README.md, and neither is SKILL.md.
26
+ assert.equal(splitSkillPath('/skills/s/skill.md').isSkillMd, false);
27
+
28
+ console.log('skill path split ok — SKILL.md routes to the skill row, everything else to skill files');
29
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.18.2",
3
+ "version": "1.18.4",
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": [
@@ -80,7 +80,7 @@ These bookend the loop. Prime/feed at the start, deposit at the end.
80
80
  ## Skill authoring
81
81
 
82
82
  - **Git is the source of truth for skills; Drafted renders, indexes, and searches.** When a repo is connected to a folder, the skills under `.agents/skills/` in that repo ARE the skills for that folder — authoring happens by committing to git, not by writing into Drafted. Drafted write paths for a repo-owned skill return `409 repo_owned` naming the repo, branch, and path to commit to. Use `repo(action="rescan")` after a push so the index reflects the change.
83
- - **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md` is required (the `readme` param) so the skill directory is self-documenting. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
83
+ - **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md` (the `readme` param) is optional but recommended — it is what renders for the skill directory on GitHub if the skill is ever exported; omitting it creates the skill with a warning. A skill is a directory: `fs(write, path="/o/<org>/skills/<slug>/README.md", content=...)` and any other supporting file work once the skill exists, and `fs(ls, path="/o/<org>/skills/<slug>")` lists them. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
84
84
  - **Knowledge goes in the wiki, procedure in skills.** Drafted owns org knowledge (wiki pages via `fs` under `/o/<org>/wiki/`); git owns reusable procedure (skills under `.agents/skills/`). Don't put a skill body in a wiki page or a knowledge doc in a SKILL.md.
85
85
  - **Machine-specific build output is never portable.** Build `node_modules`, downloaded browsers, compiled binaries into a `.skillinstall/` directory inside the skill — Drafted always strips it on push and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe live in git/Drafted.
86
86
  - **Improve skills when you find a better way.** Fix or distill a skill that underperformed rather than leaving it stale.