drafted 1.19.9 → 1.19.16

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
@@ -198,6 +198,17 @@ function scrubLocalPathMentions(description) {
198
198
  // inside the factory so each HTTP request gets its own isolated server.
199
199
  // Stdio mode uses the `mcpServer` singleton (built once at module load).
200
200
 
201
+ // What an fs(rm) on a project path actually targets. A layer or lane path carries
202
+ // no filename, so before this predicate existed it fell through to the "no file
203
+ // path = archive the project" branch and archived the WHOLE project — returning a
204
+ // normal-looking success. That is how "Ai For Coaches Talk" was lost to an
205
+ // fs(rm) on /slides/deck. Only the bare project path may archive.
206
+ export function rmScope(layer, lane, filename) {
207
+ if (filename) return 'file';
208
+ if (layer || lane) return 'directory';
209
+ return 'project';
210
+ }
211
+
201
212
  // ── Org-ambiguity policy (the one decision core) ─────────────────
202
213
  // The org guard inside the factory plumbs session/HTTP state into this
203
214
  // side-effect-free predicate, which IS the policy (DRAFT-36 "one rule"). Top-level
@@ -391,6 +402,7 @@ const TOOL_ANNOTATIONS = {
391
402
  session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param) or `update` (update the npm-installed Drafted MCP on THIS machine when whoami reports mcpUpdate.stale — run it yourself rather than handing the user a shell command; `dryRun` reports without starting it).' },
392
403
 
393
404
  // Comments — the review loop agents could previously only reach over raw HTTP
405
+ action: { title: 'Propose an action', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Propose an outward effect for a HUMAN to approve — sharing a project or file, changing someone\'s role, minting a public link, or @mentioning someone in a comment. These are the operations that reach OUTSIDE the org: they grant access to a person or contact them, and they cannot be undone by editing a frame. You can propose and read; you cannot approve. Approving is the send, it happens server-side from a frozen snapshot, and there is no verb here that would let an agent trigger it. Everything INSIDE the org — writing frames, wiki pages, skills, task status — needs no proposal: just do it.' },
394
406
  comment: { title: 'Comments', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Read and write review comments on frames. Comments are notes ABOUT THE WORK: they attach to a frame, optionally to one element inside it, and anyone who can see the frame sees them. Dispatch by `action`: list (paginated, compact mode), add (with an optional element anchor or a reply), resolve, reopen, delete. Use this to leave findings a human can action in place, and to read the feedback they left you.' },
395
407
 
396
408
  // Canvas / view
@@ -2547,6 +2559,58 @@ tool('tour', {
2547
2559
  } catch (error) { return err(error); }
2548
2560
  });
2549
2561
 
2562
+ // ── action: propose an outward effect; a HUMAN approving is what executes ──
2563
+ //
2564
+ // There is deliberately NO execute/approve verb here, on any transport. The gate
2565
+ // is not "the agent may send once approved" — approving IS the send, server-side,
2566
+ // from a frozen snapshot. A spendable permission is a capability that can later be
2567
+ // re-pointed at content the human never read. See SECURITY.md.
2568
+ tool('action', {
2569
+ action: z.enum(['propose', 'list', 'read']).describe('propose: ask a human to authorise one outward effect, or a chain of them approved together. list: your org\'s pending proposals. read: one chain with every payload as the approver will see it.'),
2570
+ type: z.string().optional().describe('[propose] One of: share_project, share_frame, change_share_role, create_public_frame_link, create_public_lane_link, comment_mention. Anything else is refused — the list is fixed in source, not data.'),
2571
+ payload: z.string().optional().describe('[propose] JSON object for this type. share_project {projectId,email,role}; share_frame {frameId,email,role}; change_share_role {shareId,role}; create_public_frame_link {frameId}; create_public_lane_link {projectId,layer,lane}; comment_mention {frameId,body,email}. Optional expiresInHours on the share types.'),
2572
+ actions: z.string().optional().describe('[propose] JSON array of {type,payload} to approve TOGETHER as one chain — e.g. share with three people, then comment tagging them. One screen, one decision. Execution is sequential and stops at the first failure; a sent email cannot be rolled back, so it is never all-or-nothing.'),
2573
+ groupId: z.string().optional().describe('[read] The chain to read.'),
2574
+ reason: z.string().optional().describe('[propose] One line telling the approver WHY. They see it next to what the action does.'),
2575
+ limit: z.number().optional().describe('[list] Default 25.'),
2576
+ offset: z.number().optional().describe('[list] Default 0.'),
2577
+ compact: z.boolean().optional().describe('[list] Identity fields only.'),
2578
+ }, async ({ action, type, payload, actions, groupId, reason, limit, offset, compact }) => {
2579
+ try {
2580
+ if (action === 'propose') {
2581
+ let items;
2582
+ if (actions) {
2583
+ const parsed = JSON.parse(actions);
2584
+ if (!Array.isArray(parsed) || !parsed.length) throw new Error('actions must be a non-empty JSON array of {type,payload}');
2585
+ items = parsed;
2586
+ } else {
2587
+ if (!type) throw new Error('type is required for action propose (or pass `actions` for a chain)');
2588
+ items = [{ type, payload: payload ? JSON.parse(payload) : {} }];
2589
+ }
2590
+ const result = await api('POST', '/api/actions', {
2591
+ actions: items,
2592
+ ...(reason ? { reason: String(reason) } : {}),
2593
+ });
2594
+ return ok({
2595
+ ...result,
2596
+ next: 'Proposed. A person must approve this in Drafted before anything is sent — you cannot approve it yourself, and there is no verb here that would let you.',
2597
+ });
2598
+ }
2599
+ if (action === 'read') {
2600
+ if (!groupId) throw new Error('groupId is required for action read');
2601
+ return ok(await api('GET', `/api/actions/groups/${encodeURIComponent(groupId)}`));
2602
+ }
2603
+ const qs = new URLSearchParams();
2604
+ if (limit != null) qs.set('limit', String(limit));
2605
+ if (offset != null) qs.set('offset', String(offset));
2606
+ if (compact) qs.set('compact', '1');
2607
+ const q = qs.toString();
2608
+ return ok(await api('GET', `/api/actions${q ? '?' + q : ''}`));
2609
+ } catch (e) {
2610
+ return err(e);
2611
+ }
2612
+ });
2613
+
2550
2614
  tool('comment', {
2551
2615
  action: z.enum(['list', 'add', 'resolve', 'reopen', 'delete']).describe('list: read comments (paginated). add: post a comment or a reply. resolve/reopen: flip a comment\'s state. delete: remove one.'),
2552
2616
  target: z.string().optional().describe('[list, add] The frame: a path (/projects/<project>/<layer>/<lane>/<file>), a frame URL, or a frame UUID. Required for add. For list, omit it to read the whole project.'),
@@ -3562,7 +3626,19 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3562
3626
  // __archived folder. Frame/lane rm still deletes those items, but a
3563
3627
  // whole project is never hard-deleted by an agent — the web UI's
3564
3628
  // Archive bin is where a human permanently deletes.
3565
- if (!filePath) {
3629
+ // A layer or lane path has no filename, so it used to fall through to
3630
+ // the archive branch and archive the WHOLE project — with a
3631
+ // success-shaped response. /slides/deck and /slides are one keystroke
3632
+ // from the project path; only the bare project path may archive.
3633
+ const scope = rmScope(layer, lane, filename);
3634
+ if (scope === 'directory') {
3635
+ return err(new Error(
3636
+ `rm on a ${lane ? 'lane' : 'layer'} path deletes nothing — Drafted has no directory delete. ` +
3637
+ `Remove the frames individually: /projects/<project>/${layer}${lane ? '/' + lane : ''}/<file>. ` +
3638
+ `To archive the whole project, address the project itself: /projects/<project>`
3639
+ ));
3640
+ }
3641
+ if (scope === 'project') {
3566
3642
  const pid = getState().projectId;
3567
3643
  if (!pid) return err(new Error('could not resolve project id for archive'));
3568
3644
  const result = await api('PATCH', `/api/project/${pid}`, { folder: '__archived' }, orgHeader);
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
5
5
  import { mkdtempSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { tmpdir } from 'node:os';
8
- import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin } from './server.mjs';
8
+ import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin, rmScope } from './server.mjs';
9
9
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
10
10
 
11
11
  // One rule governs create AND fork (a fork is a create). A write proceeds when its
@@ -168,6 +168,16 @@ assert.equal(stripUrlOrigin('/o/acme/wiki/x'), '/o/acme/wiki/x', 'plain paths pa
168
168
  assert.equal(stripUrlOrigin('not a url'), 'not a url', 'non-URL input passes through');
169
169
  assert.equal(stripUrlOrigin('/f/00000000-0000-0000-0000-000000000000'), '/f/00000000-0000-0000-0000-000000000000', '/f/ frame links pass through');
170
170
 
171
+ // fs(rm) scope: only a BARE project path may archive a project. A lane path
172
+ // (/slides/deck) and a layer path (/slides) both parse with filename=null and
173
+ // used to fall through to the archive branch, silently archiving the whole
174
+ // project with a success-shaped response.
175
+ assert.equal(rmScope('slides', 'deck', '12.html'), 'file', 'a full frame path removes that frame');
176
+ assert.equal(rmScope('slides', null, '12.html'), 'file', 'a layer-root file path removes that frame');
177
+ assert.equal(rmScope('slides', 'deck', null), 'directory', 'a LANE path must never archive the project');
178
+ assert.equal(rmScope('slides', null, null), 'directory', 'a LAYER path must never archive the project');
179
+ assert.equal(rmScope(null, null, null), 'project', 'only the bare project path archives the project');
180
+
171
181
  console.log('org-guard policy OK');
172
182
  // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
173
183
  // loop that keeps the event loop alive. Assertions are done — exit deterministically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.9",
3
+ "version": "1.19.16",
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": [