drafted 1.19.31 → 1.19.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/server.mjs CHANGED
@@ -331,9 +331,17 @@ export function matchesAllTerms(parts, query) {
331
331
  // A hit with no snippet matched on the LABEL, not on content — say which, so the
332
332
  // agent knows whether the frame's text is evidence or just its filename.
333
333
  // Pure + exported (asserted in mcp/test-search-terms.mjs).
334
- export function formatFrameHits(frames, { limit = 25, snippetChars = 200 } = {}) {
334
+ /**
335
+ * `org` overrides how the org is SPELLED (use the form the caller addressed, so
336
+ * every section of one result reads the same), and `folderOf` supplies the
337
+ * project's folder — frame hits do not carry it, so without this the path drops
338
+ * a segment and stops matching what fs(ls) prints for the same project.
339
+ */
340
+ export function formatFrameHits(frames, { limit = 25, snippetChars = 200, org = null, folderOf = null } = {}) {
335
341
  return frames.slice(0, limit).map((f) => {
336
- const path = `/o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/');
342
+ const folder = folderOf ? folderOf(f.projectId) : '';
343
+ const proj = [folder, f.projectSlug || f.projectId].filter(Boolean).join('/');
344
+ const path = `/o/${org || f.orgSlug || f.orgId}/projects/${proj}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/');
337
345
  const snip = clip(String(f.snippet || '').replace(/\s+/g, ' ').trim(), snippetChars);
338
346
  return snip ? ` ${path}\n ${snip}` : ` ${path} (label match)`;
339
347
  }).join('\n');
@@ -1159,8 +1167,16 @@ function workingOrgSource() {
1159
1167
  // One `ls` level of folders, from the org's folder list. Defined inside the
1160
1168
  // closure because `api` is — it carries this session's auth and org scope.
1161
1169
  async function listSubfolders(base, prefix, orgHeader) {
1162
- const rows = await api('GET', '/api/folders', undefined, orgHeader).catch(() => []);
1163
- return subfolderEntries(base, prefix, (Array.isArray(rows) ? rows : []).map((r) => r?.name));
1170
+ const [rows, repos] = await Promise.all([
1171
+ api('GET', '/api/folders', undefined, orgHeader).catch(() => []),
1172
+ // Best-effort: a failure just omits the git-owned note, never breaks ls.
1173
+ api('GET', '/api/repos', undefined, orgHeader).catch(() => null),
1174
+ ]);
1175
+ const byFolder = new Map(
1176
+ (Array.isArray(repos?.repos) ? repos.repos : []).map((r) => [r.folderName || '', `${r.slug}@${r.branch}`])
1177
+ );
1178
+ return subfolderEntries(base, prefix, (Array.isArray(rows) ? rows : []).map((r) => r?.name),
1179
+ (name) => byFolder.get(name) || null);
1164
1180
  }
1165
1181
 
1166
1182
  async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
@@ -3298,7 +3314,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3298
3314
  }
3299
3315
  const scope = orgFromPath ? `/o/${orgFromPath}` : '';
3300
3316
  const leg = async (fn) => { try { return { v: await fn() }; } catch (e) { return { e: e?.message || String(e) }; } };
3301
- const [wiki, skills, projects, frames] = await Promise.all([
3317
+ const [wiki, skills, projects, frames, repos] = await Promise.all([
3302
3318
  leg(() => api('GET', `/api/wiki/search?q=${encodeURIComponent(q)}&limit=10`, undefined, orgHeader)),
3303
3319
  leg(() => api('GET', `/api/skills/search?q=${encodeURIComponent(q)}`, undefined, orgHeader)),
3304
3320
  leg(() => api('GET', '/api/projects', undefined, orgHeader)),
@@ -3306,6 +3322,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3306
3322
  // fact — the row cap is applied server-side, so a client-side filter can be
3307
3323
  // handed a page already filled by other orgs' hits.
3308
3324
  leg(() => withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}&limit=15`, undefined, orgHeader))),
3325
+ // Which folders git owns, so the result can say which hits are read-only
3326
+ // in Drafted. One cheap org-scoped call; a failure just omits the note.
3327
+ leg(() => api('GET', '/api/repos', undefined, orgHeader)),
3309
3328
  ]);
3310
3329
  // A root search IS the prior-art search both gates ask for — it read the wiki
3311
3330
  // and the skill library. Not crediting it would send the agent back to run
@@ -3340,17 +3359,41 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3340
3359
  // filter reused that empty set, it silently zeroed BOTH sections. A
3341
3360
  // search that found nothing because of how you spelled the org is exactly
3342
3361
  // the invisible false negative this whole guard exists to prevent.
3343
- section('Projects', projects, (v) => {
3344
- const rows = (Array.isArray(v?.projects) ? v.projects : []).filter(x => x.folder !== '__archived');
3345
- const hits = rows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q)).slice(0, 10);
3346
- return hits.length ? hits.map(x => ` /o/${x.orgSlug || x.orgId}/projects/${x.slug || x.id}`).join('\n') : '';
3362
+ // A printed path must BE the address fs(ls) gives for the same thing —
3363
+ // otherwise copying it from a search result lands somewhere else, or
3364
+ // nowhere. Two ways that broke: the project's folder was dropped
3365
+ // (`projects/wrkout-week-reels` for a project fs(ls) shows at
3366
+ // `projects/Marketing/wrkout-week-reels`), and the org was spelled from
3367
+ // the row while wiki/skills used the form the caller typed — one result,
3368
+ // two spellings of one org.
3369
+ const projectRows = (Array.isArray(projects.v?.projects) ? projects.v.projects : [])
3370
+ .filter(x => x.folder !== '__archived');
3371
+ const folderById = new Map(projectRows.map(x => [x.id, x.folder || '']));
3372
+ const orgAs = orgFromPath || null;
3373
+ section('Projects', projects, () => {
3374
+ const hits = projectRows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q)).slice(0, 10);
3375
+ return hits.length
3376
+ ? hits.map(x => ` /o/${orgAs || x.orgSlug || x.orgId}/projects/${[x.folder, x.slug || x.id].filter(Boolean).join('/')}`).join('\n')
3377
+ : '';
3347
3378
  });
3348
3379
  section('Frames', frames, (v) => {
3349
3380
  const hits = Array.isArray(v) ? v : (v?.results || []);
3350
3381
  const note = archivedNote(Array.isArray(v) ? 0 : (v?.archivedCount || 0));
3351
- return hits.length ? formatFrameHits(hits, { limit: 15 }) + note : (note ? note.trimStart() : '');
3382
+ return hits.length
3383
+ ? formatFrameHits(hits, { limit: 15, org: orgAs, folderOf: (id) => folderById.get(id) || '' }) + note
3384
+ : (note ? note.trimStart() : '');
3352
3385
  });
3353
3386
 
3387
+ // Which of these hits are read-only in Drafted. A git-owned folder's wiki
3388
+ // and skills answer 409 repo_owned on write, and nothing else in the
3389
+ // output distinguishes them from an ordinary path — the failure lands at
3390
+ // write time, after the edit is composed.
3391
+ const linked = (Array.isArray(repos.v?.repos) ? repos.v.repos : [])
3392
+ .map(r => `${r.folderName ? r.folderName : '(org root)'} → ${r.slug}@${r.branch}`);
3393
+ if (linked.length) {
3394
+ out.push(`Git-owned (edits are commits, not fs writes): ${linked.join('; ')}`);
3395
+ }
3396
+
3354
3397
  const searched = orgFromPath || org || null;
3355
3398
  // ALWAYS say where this looked. "No matches" is only safe to act on when
3356
3399
  // the reader can see the scope it was computed over.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.31",
3
+ "version": "1.19.32",
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": [
@@ -120,7 +120,11 @@ export function liftFolderScope(path, to) {
120
120
  * org's folder names (each of which is its full path). One segment deeper only —
121
121
  * `ls` shows a level, not a tree.
122
122
  */
123
- export function subfolderEntries(base, prefix, names) {
123
+ /** `repoOf(fullFolderName)` -> "slug@branch" when git owns that folder, else
124
+ * null. A git-owned folder's wiki and skills answer 409 repo_owned on write,
125
+ * and without this the listing renders it identically to a writable one — the
126
+ * agent finds out at write time, after composing the edit. */
127
+ export function subfolderEntries(base, prefix, names, repoOf = null) {
124
128
  const depth = prefix ? normalizeFolder(prefix).split('/').length : 0;
125
129
  const kids = new Set();
126
130
  for (const raw of names || []) {
@@ -130,12 +134,18 @@ export function subfolderEntries(base, prefix, names) {
130
134
  const segs = name.split('/');
131
135
  if (segs.length > depth) kids.add(segs[depth]);
132
136
  }
133
- return [...kids].sort().map((name) => ({
134
- name,
135
- type: 'directory',
136
- path: `${String(base || '').replace(/\/+$/, '')}/${name}`,
137
- hint: 'folder — carries its own wiki, skills, tasks and projects',
138
- }));
137
+ const full = (name) => normalizeFolder([prefix, name].filter(Boolean).join('/'));
138
+ return [...kids].sort().map((name) => {
139
+ const repo = repoOf ? repoOf(full(name)) : null;
140
+ return {
141
+ name,
142
+ type: 'directory',
143
+ path: `${String(base || '').replace(/\/+$/, '')}/${name}`,
144
+ hint: repo
145
+ ? `folder — carries its own wiki, skills, tasks and projects · GIT-OWNED by ${repo} (edits are commits)`
146
+ : 'folder — carries its own wiki, skills, tasks and projects',
147
+ };
148
+ });
139
149
  }
140
150
 
141
151
  /**