drafted 1.24.3 → 1.24.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.
Files changed (46) hide show
  1. package/README.md +15 -34
  2. package/cli/drafted.mjs +1 -1
  3. package/mcp/chunked-write.mjs +178 -0
  4. package/mcp/server.mjs +114 -12
  5. package/package.json +21 -27
  6. package/skills/import-website-to-drafted.md +329 -0
  7. package/agent-instructions/global.md +0 -3
  8. package/cli/google-setup.sh +0 -186
  9. package/cli/mail-watch.mjs +0 -237
  10. package/cli/open-url.mjs +0 -17
  11. package/cli/repo-scan.mjs +0 -240
  12. package/cli/skill-setup-trust.mjs +0 -17
  13. package/install-mcp.sh +0 -1440
  14. package/mcp/active-project-store.mjs +0 -81
  15. package/mcp/gates.mjs +0 -206
  16. package/mcp/gws-status.mjs +0 -31
  17. package/mcp/repo-address.mjs +0 -215
  18. package/mcp/test-file-path-text.mjs +0 -58
  19. package/mcp/test-org-guards.mjs +0 -234
  20. package/mcp/test-project-index.mjs +0 -88
  21. package/mcp/test-search-terms.mjs +0 -79
  22. package/mcp/test-skill-paths.mjs +0 -29
  23. package/mcp/widgets/canvas-overview.html +0 -229
  24. package/mcp/widgets/frame-preview.html +0 -162
  25. package/plugin/commands/create-project.md +0 -20
  26. package/plugin/commands/create-skill.md +0 -18
  27. package/plugin/commands/extract.md +0 -16
  28. package/plugin/commands/improve-project-harness.md +0 -16
  29. package/plugin/commands/improve-skill.md +0 -14
  30. package/plugin/commands/improve-wiki.md +0 -14
  31. package/plugin/commands/ingest.md +0 -20
  32. package/plugin/commands/onboard-drafted.md +0 -18
  33. package/plugin/skills/drafted/SKILL.md +0 -102
  34. package/server/lib/umami.mjs +0 -164
  35. package/src/shared/anchor-scope.mjs +0 -98
  36. package/src/shared/excalidraw.mjs +0 -94
  37. package/src/shared/folder-path.mjs +0 -176
  38. package/src/shared/gate-budget.mjs +0 -30
  39. package/src/shared/minion-presets.mjs +0 -67
  40. package/src/shared/okf-log.mjs +0 -62
  41. package/src/shared/record-conformance.mjs +0 -167
  42. package/src/shared/root-route.mjs +0 -45
  43. package/src/shared/task-id.mjs +0 -54
  44. package/src/shared/test-excalidraw-merge.mjs +0 -52
  45. package/src/shared/wiki-excalidraw.mjs +0 -90
  46. /package/{src/shared → shared}/constants.mjs +0 -0
package/README.md CHANGED
@@ -1,51 +1,32 @@
1
1
  # Drafted
2
2
 
3
- Multi-tenant shared surface for AI-human collaboration. Drafted organizes agent-produced work into **projects** (frames on a zoomable real-time surface), **skills** (reusable operating procedures agents load), and a **wiki** (durable org knowledge) — with MCP tools for agents and a browser UI for humans.
4
-
5
- - Product vision and positioning: [PRODUCT.md](PRODUCT.md)
6
- - Architecture, development, and operations: [AGENTS.md](AGENTS.md)
7
- - Live: [drafted.live](https://drafted.live)
8
-
9
- ## OKF-native
10
-
11
- Drafted is a native producer and consumer of the **Open Knowledge Format (OKF) v0.1** ([GoogleCloudPlatform/knowledge-catalog](https://github.com/GoogleCloudPlatform/knowledge-catalog)):
12
-
13
- - **The org wiki is an OKF v0.1 bundle.** Every page carries conformant YAML frontmatter (required `type`, one-line `description`, `tags`, synthesized `timestamp`; unknown keys preserved). `index.md` at every level is synthesized, `log.md` keeps the date-grouped change history, links resolve with or without `.md`, and broken links are legal. The wiki UI shows a live OKF conformance badge.
14
- - **Bundle exchange everywhere.** Export/import the wiki (`GET /api/wiki/export.tar.gz`, `POST /api/wiki/import`), the skill library (`skills/<slug>/SKILL.md` layout), and whole projects (`<layer>/<lane>/<file>.md` concepts; markdown links round-trip as connectors) — via HTTP or the MCP `wiki`, `skill`, and `project` tools, with dry-run reports.
3
+ Shared surface for AI-human collaboration. MCP server and CLI for organizing agent work into projects, skills, and durable knowledge.
15
4
 
16
5
  ## Install
17
6
 
18
- **Claude Code, Codex, or any MCP client** — installs the MCP server, the Drafted skill, *and* the eight slash commands (into `~/.claude/commands/drafted/` and `~/.codex/prompts/drafted/`):
19
-
20
7
  ```bash
21
- # macOS / Linux — download, then run the local file:
22
- curl -fsSL https://drafted.live/install.sh -o /tmp/install-drafted.sh
23
- bash /tmp/install-drafted.sh
24
-
25
- # Windows (PowerShell) — download, then run the local file:
26
- Invoke-WebRequest https://drafted.live/install.ps1 -OutFile $env:TEMP\drafted-install.ps1
27
- powershell -NoProfile -ExecutionPolicy Bypass -File $env:TEMP\drafted-install.ps1
8
+ npm install -g drafted
28
9
  ```
29
10
 
30
- > **Why not `curl | bash` / `irm | iex`?** Heuristic antivirus (Norton IDP, SmartScreen) flags the pipe-to-shell pattern as download-and-execute malware. Downloading the installer to a local file and running it avoids the false-positive block.
11
+ ## Usage
31
12
 
32
- > Adding the MCP server on its own (e.g. `claude mcp add`) gives you the tools without the skill or the commands.
13
+ ### As an MCP server (for Claude Desktop, Claude Code, Codex, Cursor)
33
14
 
34
- **Claude on the web (claude.ai) and Cowork** — install the plugin, not the bare connector. The plugin bundles the Drafted connector *plus* the Drafted skill and the eight slash commands (`/drafted:onboard-drafted`, `/drafted:create-project`, `/drafted:create-skill`, `/drafted:ingest`, `/drafted:extract`, `/drafted:improve-wiki`, `/drafted:improve-skill`, `/drafted:improve-project-harness`); adding the connector URL on its own gives you the tools with none of the guidance.
15
+ The MCP server is automatically available after install:
35
16
 
36
- > 1. Settings → Customize plugins → Add → Add marketplace → `https://github.com/ddfourtwo/drafted-web` → install **Drafted**.
37
- > 2. Settings → Connectors → find **Drafted** → **Connect**, then approve the sign-in.
38
- >
39
- > Both halves matter: the plugin carries the skill and commands, the connector carries the tools. Installing the plugin does not sign you in to the connector.
17
+ ```bash
18
+ drafted-mcp
19
+ ```
40
20
 
41
- ## Quick start (developing Drafted itself)
21
+ ### As a CLI
42
22
 
43
23
  ```bash
44
- npm install && npm run dev # Docker services + schema + server with hot reload
24
+ drafted login
25
+ drafted ls
26
+ drafted write designs/default/hero.html < design.html
45
27
  ```
46
28
 
47
- - App: http://localhost:3477
48
- - Email inbox (magic links): http://localhost:8025
49
- - MinIO console: http://localhost:9001
29
+ ## Links
50
30
 
51
- Sign in with any email and grab the magic link from Mailpit.
31
+ - **App**: https://drafted.live
32
+ - **Docs**: https://drafted.live
package/cli/drafted.mjs CHANGED
@@ -1270,7 +1270,7 @@ function resolveWindowType(source, options, layer) {
1270
1270
  // Command: add
1271
1271
  program
1272
1272
  .command('add <source>')
1273
- .description('Add frame (upload file or URL to server)')
1273
+ .description('Add frame (upload file or URL to server). Text files (.html/.md/.txt) are stored as inline frames through the same write path a normal save takes — not as binary assets.')
1274
1274
  .option('--label <label>', 'Display label')
1275
1275
  .option('--layer <layer>', 'Layer override (e.g., brand-assets, wireframes, designs)')
1276
1276
  .option('--width <width>', 'Frame width in pixels')
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Chunked `fs write` assembly.
3
+ *
4
+ * A remote-transport MCP session has no `file_path`: the server cannot read the
5
+ * caller's disk, so the param is stripped from the advertised schema. That left
6
+ * exactly one route for a large text frame (a ~1 MB app-frame .html): inline
7
+ * `content`, i.e. ONE tool call carrying the whole file. A model cannot emit
8
+ * that in a single call (output-token limit) and clients cap tool-argument
9
+ * size, so agents hand-rolled authenticated HTTP against /api/fs instead.
10
+ *
11
+ * This buffers bounded chunks in the MCP process and hands the joined text to
12
+ * the ONE normal write, so nothing changes about what the server stores: the
13
+ * frame is never half-served, version history stays one row, app-frame
14
+ * detection (looksLikeAppFrame) sees the whole document, and
15
+ * `expectedUpdatedAt` is enforced once — on the write that actually lands.
16
+ *
17
+ * Additive: with no chunk params the fs write handler behaves exactly as
18
+ * before, and stdio callers keep `file_path`.
19
+ *
20
+ * Kept out of server.mjs so it can be tested without importing the MCP server
21
+ * (which opens a WebSocket at module scope and pins the event loop).
22
+ */
23
+
24
+ // One chunk is a tool argument, so it is bounded on purpose: 8 MB is ~8x a
25
+ // 1 MB frame split into a handful of calls and still far under the server's
26
+ // 50 MB JSON body limit. A bigger single chunk gets a clear error instead of a
27
+ // request a client may silently truncate.
28
+ export const MAX_CHUNK_BYTES = 8 * 1024 * 1024;
29
+ // A whole assembled frame: generous for an app frame, small enough that a runaway
30
+ // loop of uploads cannot creep toward the process heap.
31
+ export const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
32
+ export const MAX_CHUNKS = 1024;
33
+ // An upload nobody finished is garbage after half an hour. Every chunk sweeps,
34
+ // so an abandoned upload costs nothing beyond that window.
35
+ export const IDLE_TTL_MS = 30 * 60 * 1000;
36
+ // Process-wide budget across all in-flight uploads. Per-upload caps alone let
37
+ // N sessions each hold MAX_UPLOAD_BYTES; this is the real ceiling.
38
+ export const MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
39
+
40
+ /** key -> { total, chunks: Map<index, string>, bytes, opts, content|null, touchedAt } */
41
+ const uploads = new Map();
42
+
43
+ /** Test seam: an emptied store between cases. */
44
+ export function resetChunkedWrites() {
45
+ uploads.clear();
46
+ }
47
+
48
+ export function bufferedBytes() {
49
+ let total = 0;
50
+ for (const u of uploads.values()) total += u.bytes;
51
+ return total;
52
+ }
53
+
54
+ export function dropUpload(key) {
55
+ uploads.delete(key);
56
+ }
57
+
58
+ export function inFlightCount() {
59
+ return uploads.size;
60
+ }
61
+
62
+ function byteLength(text) {
63
+ return Buffer.byteLength(String(text ?? ''), 'utf8');
64
+ }
65
+
66
+ function sweep(now) {
67
+ for (const [key, entry] of uploads) {
68
+ if (now - entry.touchedAt > IDLE_TTL_MS) uploads.delete(key);
69
+ }
70
+ }
71
+
72
+ function receivedLabel(entry) {
73
+ const indexes = [...entry.chunks.keys()].sort((a, b) => a - b);
74
+ return indexes.length ? indexes.join(', ') : 'none';
75
+ }
76
+
77
+ function missingIndexes(entry) {
78
+ const missing = [];
79
+ for (let i = 0; i < entry.total; i += 1) if (!entry.chunks.has(i)) missing.push(i);
80
+ return missing;
81
+ }
82
+
83
+ /**
84
+ * Accept one chunk. Throws on a chunk that cannot belong to this upload (so the
85
+ * caller reports it loudly and nothing is written); returns
86
+ * `{ ready: false, received, total }` while more are needed, and
87
+ * `{ ready: true, content, opts }` once every index is present.
88
+ *
89
+ * Ordering is deliberately tolerant — chunks may arrive in any order, because a
90
+ * retried call is cheaper than a whole upload restarted — but gaps are NOT
91
+ * papered over: the final chunk with anything missing throws, naming the
92
+ * missing indexes. A duplicate index throws too (only an identical re-sent
93
+ * FINAL chunk is accepted, so a failed commit — a 409 on expectedUpdatedAt,
94
+ * say — can be retried without re-sending the file).
95
+ *
96
+ * opts (metadata, state, expectedUpdatedAt) are merged across chunks: an agent
97
+ * that just read the frame can put expectedUpdatedAt on the first chunk and it
98
+ * still applies to the write, which happens at the end.
99
+ */
100
+ export function acceptChunk({ key, index, total, content, opts = {}, now = Date.now() }) {
101
+ if (!key) throw new Error('chunked write needs a session + path');
102
+ if (!Number.isInteger(index) || index < 0) throw new Error('chunkIndex must be a non-negative integer');
103
+ if (!Number.isInteger(total) || total < 1) throw new Error('chunkTotal must be a positive integer');
104
+ if (total > MAX_CHUNKS) throw new Error(`chunkTotal ${total} exceeds the ${MAX_CHUNKS}-chunk limit — send larger chunks`);
105
+ if (index >= total) throw new Error(`chunkIndex ${index} is out of range for a ${total}-chunk write (indexes are 0..${total - 1})`);
106
+ if (typeof content !== 'string') throw new Error('each chunk needs its slice of the file in content');
107
+
108
+ sweep(now);
109
+
110
+ // chunk 0 always starts over, so an agent whose upload went wrong can restart
111
+ // by re-sending the first chunk instead of waiting out the TTL.
112
+ let entry = index === 0 ? null : uploads.get(key);
113
+ if (!entry) {
114
+ entry = { total, chunks: new Map(), bytes: 0, opts: {}, content: null, touchedAt: now };
115
+ uploads.set(key, entry);
116
+ } else if (entry.total !== total) {
117
+ throw new Error(`chunkTotal changed mid-upload (${entry.total} → ${total}) — resend chunk 0 to restart`);
118
+ }
119
+
120
+ // Every index is in: this is a repeat of a final chunk whose commit failed
121
+ // (a 409 on expectedUpdatedAt, a gate). The assembled text is unchanged, so
122
+ // re-attempting the write costs no re-upload.
123
+ if (entry.content !== null) {
124
+ if (index === entry.total - 1) {
125
+ for (const [k, v] of Object.entries(opts)) if (v !== undefined) entry.opts[k] = v;
126
+ return { ready: true, content: entry.content, opts: entry.opts, retried: true };
127
+ }
128
+ throw new Error(`this upload already completed (all ${entry.total} chunks received) — chunk ${index} is not part of it. Nothing was written; resend chunk 0 to start a new upload.`);
129
+ }
130
+
131
+ if (entry.chunks.has(index)) {
132
+ throw new Error(
133
+ `chunk ${index} was already received (chunks so far: ${receivedLabel(entry)} of ${entry.total}). `
134
+ + 'Send the chunks that are still missing, or resend chunk 0 to restart the upload. Nothing was written.',
135
+ );
136
+ }
137
+
138
+ const size = byteLength(content);
139
+ if (size > MAX_CHUNK_BYTES) {
140
+ throw new Error(`chunk ${index} is ${size} bytes, over the ${MAX_CHUNK_BYTES}-byte chunk limit — split it into smaller chunks`);
141
+ }
142
+ if (entry.bytes + size > MAX_UPLOAD_BYTES) {
143
+ throw new Error(`this file is over the ${MAX_UPLOAD_BYTES}-byte chunked-write limit (${entry.bytes + size} bytes so far) — chunked writes are for text frames, not bulk media`);
144
+ }
145
+ if (bufferedBytes() + size > MAX_BUFFERED_BYTES) {
146
+ throw new Error('the server is holding too many in-flight chunked writes right now — finish or abandon one and retry');
147
+ }
148
+
149
+ entry.chunks.set(index, content);
150
+ entry.bytes += size;
151
+ entry.touchedAt = now;
152
+ for (const [k, v] of Object.entries(opts)) if (v !== undefined) entry.opts[k] = v;
153
+
154
+ if (index !== entry.total - 1) {
155
+ return { ready: false, received: entry.chunks.size, total: entry.total };
156
+ }
157
+
158
+ const missing = missingIndexes(entry);
159
+ if (missing.length) {
160
+ const plural = missing.length > 1;
161
+ throw new Error(
162
+ `chunked write is missing chunk${plural ? 's' : ''} ${missing.join(', ')} of ${entry.total} `
163
+ + `(received: ${receivedLabel(entry)}). Nothing was written — send the missing chunk${plural ? 's' : ''}, `
164
+ + `then resend chunk ${entry.total - 1} to finish.`,
165
+ );
166
+ }
167
+
168
+ const parts = [];
169
+ for (let i = 0; i < entry.total; i += 1) parts.push(entry.chunks.get(i));
170
+ const joined = parts.join('');
171
+ // Keep the joined text (not the chunk slices) so a retried commit is cheap;
172
+ // the slices are dropped here, halving what a finished upload holds.
173
+ entry.content = joined;
174
+ entry.chunks = new Map();
175
+ entry.bytes = byteLength(joined);
176
+ entry.touchedAt = now;
177
+ return { ready: true, content: joined, opts: entry.opts };
178
+ }
package/mcp/server.mjs CHANGED
@@ -19,13 +19,14 @@ import { AsyncLocalStorage } from 'node:async_hooks';
19
19
  import { z } from 'zod';
20
20
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
21
21
  import WebSocket from 'ws';
22
- import { LAYERS } from '../src/shared/constants.mjs';
22
+ import { LAYERS } from '../shared/constants.mjs';
23
23
  import { liftFolderScope, normalizeFolder, rootEntries, splitFolderScope, subfolderEntries } from '../src/shared/folder-path.mjs';
24
24
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
25
25
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
26
26
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, charWindow, READ_PAGE_CHARS, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
27
27
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
28
28
  import { probeRepo, resolveAddress, addressNote } from './repo-address.mjs';
29
+ import { acceptChunk, dropUpload } from './chunked-write.mjs';
29
30
 
30
31
  // Node 18+ only (global fetch). A GUI client that resolves an old Node (Claude
31
32
  // Desktop put nvm v16 first on its PATH, 2026-10-06) otherwise fails every tool call
@@ -1698,6 +1699,28 @@ async function resolveFolderQualifiedProject(parts, orgHint = null) {
1698
1699
  return null;
1699
1700
  }
1700
1701
 
1702
+ /**
1703
+ * The system project behind a /tasks root, as the project meta every resolver
1704
+ * hands out.
1705
+ *
1706
+ * POST /api/tasks/project is the ONLY endpoint that can READ that row: a tasks
1707
+ * root is a project with kind='tasks', and /api/projects excludes system
1708
+ * projects by design (`userProjectsOnly`) because a tasks root is not an
1709
+ * entry in the /projects grammar. So resolveProjectRef — which resolves by
1710
+ * LISTING /api/projects — can never find one, and /tasks used to work only
1711
+ * because of a bare-key cache entry seeded moments earlier. An org-addressed
1712
+ * path bypassed that rung by using a different cache key (`<org>|<id>`), which
1713
+ * is why /o/<org>/tasks answered `project not found: <uuid>/tasks` with a uuid
1714
+ * the caller never typed, while `/o/<org>` listed `tasks` as a directory.
1715
+ * Measured on prod 2026-10-21 from a session working in another org.
1716
+ *
1717
+ * Keep the row in hand and pass it on: a lookup here is guaranteed to miss.
1718
+ */
1719
+ function tasksProjectMeta(sys) {
1720
+ if (!sys?.id) return null;
1721
+ return { id: sys.id, slug: sys.slug || null, name: sys.name || null, orgId: sys.orgId || null, orgSlug: sys.orgSlug || null, folder: sys.folder || '' };
1722
+ }
1723
+
1701
1724
  /**
1702
1725
  * Resolve a project argument to its meta, accepting the pseudo-filesystem path
1703
1726
  * form (/projects/<name> or /projects/<folder>/<name>) OR a bare slug / name /
@@ -1751,12 +1774,14 @@ async function resolveFsFramePath(path) {
1751
1774
  // path that this tool rejects is the worst kind of dead end. Checked after the
1752
1775
  // /projects form so the raw `/projects/tasks/tasks/...` URL still works.
1753
1776
  const taskForm = !urlForm && (p.match(/^\/o\/([^/]+)\/(?:(.+?)\/)?tasks\/(.+)$/) || p.match(/^()()\/tasks\/(.+)$/));
1777
+ let taskMeta = null;
1754
1778
  if (taskForm) {
1755
1779
  const sys = await api('POST', '/api/tasks/project', { create: false }, {
1756
1780
  ...(taskForm[1] ? { 'X-Drafted-Org': decodeURIComponent(taskForm[1]) } : {}),
1757
1781
  ...(taskForm[2] ? { 'X-Drafted-Folder': taskForm[2] } : {}),
1758
1782
  }).catch((e) => (e.status === 404 ? null : Promise.reject(e)));
1759
1783
  if (!sys?.id) throw new Error(`Not found: ${path} — this org has no tasks yet.`);
1784
+ taskMeta = tasksProjectMeta(sys);
1760
1785
  fsPath = `/projects/${sys.id}/tasks/${taskForm[3]}`;
1761
1786
  }
1762
1787
  if (!fsPath.startsWith('/projects')) {
@@ -1783,7 +1808,9 @@ async function resolveFsFramePath(path) {
1783
1808
  throw new Error('Path must include a file: /projects/<project>/<layer>/<lane>/<file>');
1784
1809
  }
1785
1810
  }
1786
- const meta = await resolveProjectRef(projectRef);
1811
+ // Same rule as the fs tool's /projects branch: the tasks row is already in
1812
+ // hand, and it is not in any listing resolveProjectRef could read.
1813
+ const meta = (taskMeta && projectRef === taskMeta.id) ? taskMeta : await resolveProjectRef(projectRef);
1787
1814
  if (!meta) throw new Error(`Project not found: ${projectRef}`);
1788
1815
  const filePath = lane
1789
1816
  ? `${encodeURIComponent(layer)}/${encodeURIComponent(lane)}/${encodeURIComponent(filename)}`
@@ -3432,14 +3459,16 @@ server.resource('info', 'drafted://info', {
3432
3459
  };
3433
3460
  });
3434
3461
 
3435
- tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.', {
3462
+ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.\n\nLARGE WRITES (chunked): when the content is too big for ONE tool call — a ~1 MB app-frame .html, or any text write from a remote transport, which has no access to a local file — write it in ordered chunks: fs(write, path=…, chunkIndex=0, chunkTotal=N, content=<slice 0>), then 1, … N-1. Each chunk is a contiguous slice of the final file and every chunk carries the same chunkTotal. NOTHING is written until the final chunk (N-1), which performs the one ordinary write: no half-uploaded frame is ever served, no intermediate version is created, app-frame detection sees the whole document, and expectedUpdatedAt is enforced on that single write. A missing index fails at the final chunk with the missing indexes named; a duplicate index fails loudly; chunk 0 restarts a broken upload. Prefer a handful of large chunks (~64–256 KB each) over hundreds of tiny ones. Reading a big frame back is paged — see the read action chars window and nextChars.', {
3436
3463
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3437
3464
  path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
3438
3465
  to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a wiki page (/o/<org>[/<folder>]/wiki/<page>), a skill (/o/<org>/skills/<slug>), a task, a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
3439
3466
  url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
3440
3467
  query: z.string().optional().describe('[search] term to match against names/content. The path must name an org (/o/<org>) unless you belong to exactly one.'),
3441
- content: z.string().optional().describe('[write] inline HTML/markdown/text'),
3442
- file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
3468
+ content: z.string().optional().describe('[write] inline HTML/markdown/text. Too large for one tool call (a ~1 MB app-frame .html, or any write on a remote transport, which has no local path)? Send it in chunks: add chunkIndex + chunkTotal; each chunk carries one slice in content.'),
3469
+ file_path: z.string().optional().describe('[write] absolute path to a local file (stdio only — a remote transport runs on the server and cannot see your disk, so this param is not advertised there; send content= instead, chunked with chunkIndex/chunkTotal when it is too big for one call). TEXT goes through the NORMAL write path, not an asset upload: .html/.md/.txt/.json are treated like an inline save, so an HTML carrying the drafted:frame-type=app marker still becomes windowType:"app", with version history, metadata and expectedUpdatedAt all applying exactly as usual. Only genuinely binary files (images, PDFs, office documents, video) are stored as assets in object storage.'),
3470
+ chunkIndex: z.number().int().min(0).optional().describe('[write] chunked large-content write: 0-based index of THIS chunk. Pair it with chunkTotal on every chunk. Every chunk must be a contiguous slice of the final file, in order, and chunk 0 starts a fresh assembly (so a broken upload restarts by re-sending chunk 0). Only the final chunk (chunkTotal-1) writes the frame — earlier chunks store nothing, so a half-uploaded frame is never served and no intermediate version is created. Chunks may arrive out of order, but a gap fails at the final chunk with the missing indexes named, and a duplicate index fails loudly. metadata, state and expectedUpdatedAt ride on any chunk and are applied to the one write.'),
3471
+ chunkTotal: z.number().int().min(1).optional().describe('[write] chunked large-content write: total number of chunks in this write, the same value on every chunk.'),
3443
3472
  base64: z.string().optional().describe('[write] base64-encoded binary content'),
3444
3473
  googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] explicit Google Workspace type (also derived from .google-* filename extension)'),
3445
3474
  title: z.string().optional().describe('[write + googleType] title for a new native file'),
@@ -3454,10 +3483,59 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3454
3483
  lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
3455
3484
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
3456
3485
  readonly: z.boolean().optional().describe('[link] read-only frame link (symlink): mirror the SOURCE frame named by `to` (a frame UUID or /projects/<other-project>/<layer>/<lane>/<file>) into the CURRENT project, displayed read-only. The source project stays the single writer.'),
3457
- org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
3486
+ org: z.string().optional().describe('Org (id or name) for bare /wiki, /skills and /tasks paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
3458
3487
  projectId: PROJECT_OVERRIDE_PARAM,
3459
3488
  }, async (args) => {
3460
- const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, expectedUpdatedAt, readme, recursive, pattern, lines, org } = args;
3489
+ const { action, path: rawPath, to: rawTo, query, file_path, base64, googleType, title, ops, readme, recursive, pattern, lines, org, chunkIndex, chunkTotal } = args;
3490
+ // content / state / metadata / expectedUpdatedAt are reassigned by a chunked
3491
+ // write below (the assembled file replaces the per-call slice).
3492
+ let { content, state, metadata, expectedUpdatedAt } = args;
3493
+ // ── Chunked large-content write (./chunked-write.mjs) ───────────
3494
+ // A remote transport has no `file_path` (stripped from the schema — the server
3495
+ // cannot read the caller's disk) and a model cannot emit a ~1 MB app frame in
3496
+ // one tool call, so large text was previously only writable by hand-rolling
3497
+ // authenticated HTTP against /api/fs. Bounded chunks are buffered here and the
3498
+ // joined text goes through the ONE ordinary write below: the frame is never
3499
+ // half-served, version history stays one row, app-frame detection sees the whole
3500
+ // document, and expectedUpdatedAt is enforced once, on the write that lands.
3501
+ // Additive: with no chunk params the handler is unchanged, and stdio keeps file_path.
3502
+ let chunkKey = null;
3503
+ if (action === 'write' && (chunkIndex !== undefined || chunkTotal !== undefined)) {
3504
+ if (chunkIndex === undefined || chunkTotal === undefined) {
3505
+ return err(new Error('a chunked write needs both chunkIndex and chunkTotal on every chunk'));
3506
+ }
3507
+ if (googleType || /\.google-(doc|sheet|slide)$/i.test(String(rawPath))) {
3508
+ return err(new Error('chunked writes fill text frames and wiki/skill pages — a Google Workspace file takes its whole content in one call'));
3509
+ }
3510
+ // Session-scoped so two sessions writing the same path can never assemble each
3511
+ // other's bytes. Hashed because it is a session token; the key is only ever a
3512
+ // map key and is never echoed into a response or an error.
3513
+ const sessionPart = createHash('sha256').update(String(getState().sessionId || (getSessionState()._stdio ? '__stdio__' : 'unbound'))).digest('hex').slice(0, 16);
3514
+ chunkKey = `${sessionPart}\n${stripUrlOrigin(String(rawPath))}`;
3515
+ try {
3516
+ const chunk = acceptChunk({
3517
+ key: chunkKey,
3518
+ index: chunkIndex,
3519
+ total: chunkTotal,
3520
+ content,
3521
+ opts: { metadata, state, expectedUpdatedAt },
3522
+ });
3523
+ if (!chunk.ready) {
3524
+ return ok({
3525
+ path: rawPath,
3526
+ chunked: true,
3527
+ committed: false,
3528
+ received: chunk.received,
3529
+ total: chunk.total,
3530
+ note: `Chunk ${chunkIndex} stored (${chunk.received}/${chunk.total}). Nothing has been written yet — the frame is only written when the final chunk (chunkIndex ${chunk.total - 1}) arrives, so a half-uploaded frame is never served.`,
3531
+ });
3532
+ }
3533
+ content = chunk.content;
3534
+ metadata = chunk.opts.metadata;
3535
+ state = chunk.opts.state;
3536
+ expectedUpdatedAt = chunk.opts.expectedUpdatedAt;
3537
+ } catch (e) { return err(e); }
3538
+ }
3461
3539
  // Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
3462
3540
  // carry it as the per-request org scope (orgHeader), validate per-root below.
3463
3541
  // The PATH is the address; the org= param is legacy for bare roots only, and a
@@ -3815,6 +3893,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3815
3893
  const result = existing
3816
3894
  ? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content: text }, orgHeader)
3817
3895
  : await api('POST', '/api/wiki/pages', body, orgHeader);
3896
+ if (chunkKey) dropUpload(chunkKey);
3818
3897
  return ok(result || { path: canonPath, written: true });
3819
3898
  }
3820
3899
  case 'edit': {
@@ -3927,6 +4006,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3927
4006
  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.`));
3928
4007
  }
3929
4008
  const written = await api('PUT', fileUrl(existing.id), { content: text }, orgHeader);
4009
+ if (chunkKey) dropUpload(chunkKey);
3930
4010
  return ok(written || { slug, path: filePath, written: true });
3931
4011
  }
3932
4012
  if (!existing) {
@@ -3949,6 +4029,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3949
4029
  if (result?.id && existing?.archived) {
3950
4030
  try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
3951
4031
  }
4032
+ if (chunkKey) dropUpload(chunkKey);
3952
4033
  return ok(result || { slug, written: true });
3953
4034
  }
3954
4035
  case 'mv': {
@@ -3982,6 +4063,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3982
4063
  // status/assignee live in COLUMNS (projected as front matter by read/write),
3983
4064
  // so a task moved out of here stays a task.
3984
4065
  const isTasksPath = (x) => x === '/tasks' || x === '/tasks/' || String(x || '').startsWith('/tasks/');
4066
+ // The row /api/tasks/project just returned, kept so the /projects branch below
4067
+ // does not try to look it up again (see tasksProjectMeta).
4068
+ let tasksMeta = null;
3985
4069
  if (isTasksPath(p) || isTasksPath(to)) {
3986
4070
  if (['write', 'edit', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
3987
4071
  // Project-less: the org cannot be guessed (same guard as /wiki and /skills).
@@ -4005,9 +4089,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4005
4089
  if (readOnly) return err(new Error(`Not found: ${p} — this org has no tasks yet.`));
4006
4090
  return err(new Error('could not resolve the tasks project for this org'));
4007
4091
  }
4008
- // Seed the cache by id so the /projects branch resolves it without a lookup
4009
- // (its /api/projects listing is not org-scoped).
4010
- projectRefCache.set(sys.id, { id: sys.id, slug: sys.slug, name: sys.name, orgId: sys.orgId, orgSlug: sys.orgSlug });
4092
+ tasksMeta = tasksProjectMeta(sys);
4093
+ projectRefCache.set(sys.id, tasksMeta);
4011
4094
  const rewrite = (x) => `/projects/${sys.id}/tasks${x === '/tasks' || x === '/tasks/' ? '' : x.slice('/tasks'.length)}`;
4012
4095
  if (isTasksPath(p)) p = rewrite(p);
4013
4096
  if (isTasksPath(to)) to = rewrite(to);
@@ -4127,7 +4210,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4127
4210
  let lookupError = null;
4128
4211
  // The org from the path rides the lookup: without it a project in another
4129
4212
  // org was invisible and every write to it answered "project not found".
4130
- let pathProject = await resolveProjectRef(projectRef, orgFromPath || org || null).catch((e) => { lookupError = e; return null; });
4213
+ // A /tasks root is the ONE exception: it was already resolved above from the
4214
+ // only endpoint that can read it, so it must not be looked up again here —
4215
+ // /api/projects excludes system projects, so this lookup is guaranteed to
4216
+ // miss (and used to, which the caller saw as "project not found: <uuid>/tasks").
4217
+ let pathProject = (tasksMeta && projectRef === tasksMeta.id)
4218
+ ? tasksMeta
4219
+ : await resolveProjectRef(projectRef, orgFromPath || org || null).catch((e) => { lookupError = e; return null; });
4131
4220
  if (!pathProject?.id && parts.length > 1) {
4132
4221
  const found = await resolveFolderQualifiedProject(parts, orgFromPath || org || null);
4133
4222
  if (found) { projectRef = parts[found.index]; tail = parts.slice(found.index + 1); pathProject = found.meta; }
@@ -4329,10 +4418,23 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4329
4418
  else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
4330
4419
  if (metadata !== undefined) body.metadata = metadata;
4331
4420
  if (expectedUpdatedAt) body.expectedUpdatedAt = expectedUpdatedAt;
4332
- const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
4421
+ let result;
4422
+ try {
4423
+ result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
4424
+ } catch (e) {
4425
+ // A chunked write keeps its assembly here: a refusal at finalize (a
4426
+ // stale expectedUpdatedAt → 409 "conflict", an anchor gate) is
4427
+ // reported plainly, and re-sending the final chunk retries the write
4428
+ // rather than the whole upload.
4429
+ if (chunkKey) return err(e);
4430
+ throw e;
4431
+ }
4333
4432
  if (state !== undefined && result?.id) {
4334
4433
  try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
4335
4434
  }
4435
+ // Only now is the assembly spent: re-sending the final chunk after a
4436
+ // refusal retries the write instead of the whole upload.
4437
+ if (chunkKey) dropUpload(chunkKey);
4336
4438
  return ok(result);
4337
4439
  }
4338
4440
  case 'edit': {
package/package.json CHANGED
@@ -1,44 +1,38 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.24.3",
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.",
3
+ "version": "1.24.4",
4
+ "description": "Shared surface for AI-human collaboration — MCP server and CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "drafted": "./cli/drafted.mjs",
8
8
  "drafted-mcp": "./mcp/server.mjs"
9
9
  },
10
10
  "files": [
11
- "cli/",
12
11
  "mcp/",
13
- "server/lib/umami.mjs",
14
- "src/shared/",
15
- "agent-instructions/",
16
- "plugin/skills/",
17
- "plugin/commands/",
18
- "install-mcp.sh"
19
- ],
20
- "license": "MIT",
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/ddfourtwo/drafted.git"
24
- },
25
- "keywords": [
26
- "design",
27
- "canvas",
28
- "preview",
29
- "html",
30
- "claude"
12
+ "cli/",
13
+ "shared/",
14
+ "skills/",
15
+ "README.md"
31
16
  ],
32
17
  "dependencies": {
33
- "@modelcontextprotocol/ext-apps": "^1.6.0",
34
18
  "@modelcontextprotocol/sdk": "^1.27.1",
35
19
  "commander": "^11.1.0",
36
20
  "qrcode-terminal": "^0.12.0",
37
- "ws": "^8.21.3",
38
- "yaml": "^2.8.3",
21
+ "ws": "^8.16.0",
39
22
  "zod": "^4.3.6"
40
23
  },
41
- "scripts": {
42
- "postpublish": "bash ../scripts/sync-plugin.sh \"chore: sync plugin to v$npm_package_version\" && bash ../scripts/sync-web-plugin.sh \"chore: sync web-plugin to v$npm_package_version\""
43
- }
24
+ "keywords": [
25
+ "drafted",
26
+ "design",
27
+ "mcp",
28
+ "claude",
29
+ "ai",
30
+ "canvas"
31
+ ],
32
+ "author": "ddfourtwo",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/ddfourtwo/drafted.git"
36
+ },
37
+ "license": "MIT"
44
38
  }