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.
- package/README.md +15 -34
- package/cli/drafted.mjs +1 -1
- package/mcp/chunked-write.mjs +178 -0
- package/mcp/server.mjs +114 -12
- package/package.json +21 -27
- package/skills/import-website-to-drafted.md +329 -0
- package/agent-instructions/global.md +0 -3
- package/cli/google-setup.sh +0 -186
- package/cli/mail-watch.mjs +0 -237
- package/cli/open-url.mjs +0 -17
- package/cli/repo-scan.mjs +0 -240
- package/cli/skill-setup-trust.mjs +0 -17
- package/install-mcp.sh +0 -1440
- package/mcp/active-project-store.mjs +0 -81
- package/mcp/gates.mjs +0 -206
- package/mcp/gws-status.mjs +0 -31
- package/mcp/repo-address.mjs +0 -215
- package/mcp/test-file-path-text.mjs +0 -58
- package/mcp/test-org-guards.mjs +0 -234
- package/mcp/test-project-index.mjs +0 -88
- package/mcp/test-search-terms.mjs +0 -79
- package/mcp/test-skill-paths.mjs +0 -29
- package/mcp/widgets/canvas-overview.html +0 -229
- package/mcp/widgets/frame-preview.html +0 -162
- package/plugin/commands/create-project.md +0 -20
- package/plugin/commands/create-skill.md +0 -18
- package/plugin/commands/extract.md +0 -16
- package/plugin/commands/improve-project-harness.md +0 -16
- package/plugin/commands/improve-skill.md +0 -14
- package/plugin/commands/improve-wiki.md +0 -14
- package/plugin/commands/ingest.md +0 -20
- package/plugin/commands/onboard-drafted.md +0 -18
- package/plugin/skills/drafted/SKILL.md +0 -102
- package/server/lib/umami.mjs +0 -164
- package/src/shared/anchor-scope.mjs +0 -98
- package/src/shared/excalidraw.mjs +0 -94
- package/src/shared/folder-path.mjs +0 -176
- package/src/shared/gate-budget.mjs +0 -30
- package/src/shared/minion-presets.mjs +0 -67
- package/src/shared/okf-log.mjs +0 -62
- package/src/shared/record-conformance.mjs +0 -167
- package/src/shared/root-route.mjs +0 -45
- package/src/shared/task-id.mjs +0 -54
- package/src/shared/test-excalidraw-merge.mjs +0 -52
- package/src/shared/wiki-excalidraw.mjs +0 -90
- /package/{src/shared → shared}/constants.mjs +0 -0
package/README.md
CHANGED
|
@@ -1,51 +1,32 @@
|
|
|
1
1
|
# Drafted
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
## Usage
|
|
31
12
|
|
|
32
|
-
|
|
13
|
+
### As an MCP server (for Claude Desktop, Claude Code, Codex, Cursor)
|
|
33
14
|
|
|
34
|
-
|
|
15
|
+
The MCP server is automatically available after install:
|
|
35
16
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
21
|
+
### As a CLI
|
|
42
22
|
|
|
43
23
|
```bash
|
|
44
|
-
|
|
24
|
+
drafted login
|
|
25
|
+
drafted ls
|
|
26
|
+
drafted write designs/default/hero.html < design.html
|
|
45
27
|
```
|
|
46
28
|
|
|
47
|
-
|
|
48
|
-
- Email inbox (magic links): http://localhost:8025
|
|
49
|
-
- MinIO console: http://localhost:9001
|
|
29
|
+
## Links
|
|
50
30
|
|
|
51
|
-
|
|
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 '../
|
|
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
|
-
|
|
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
|
|
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 /
|
|
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,
|
|
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
|
-
|
|
4009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
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.
|
|
38
|
-
"yaml": "^2.8.3",
|
|
21
|
+
"ws": "^8.16.0",
|
|
39
22
|
"zod": "^4.3.6"
|
|
40
23
|
},
|
|
41
|
-
"
|
|
42
|
-
"
|
|
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
|
}
|