drafted 1.24.2 → 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 +189 -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
@@ -1,102 +0,0 @@
1
- ---
2
- name: drafted
3
- description: Use the Drafted producibles harness — a compounding workspace that uplifts any AI across three primitives: knowledge (the org wiki), procedures (skills), and the project surface (frames on a shared real-time canvas). Drafted is navigated like a filesystem — one fs tool, one path grammar, org first: `fs(ls, path="/")` lists your orgs, then `/o/<org>/<root>/...`. Prime from the harness before working, build durable artifacts as frames instead of burying output in chat, and deposit what you learned back so the next session starts smarter. When Google Drive is connected, prefer Google Workspace frames for docs, sheets, and slides. When the local `gws` CLI is signed in, read the user's Gmail, Drive and Calendar with it as source material and build the result in Drafted.
4
- ---
5
-
6
- # Drafted — a producibles harness that compounds
7
-
8
- Drafted makes any AI more effective by giving it a memory and a workspace that get better with use. It has three primitives that layer:
9
-
10
- - **Knowledge — the org wiki.** Durable facts, decisions, conventions. *More knowledge = less searching.*
11
- - **Procedures — skills.** Reusable SOPs the org has encoded. *Better skills = more stable process.*
12
- - **Surface — projects.** Reviewable work as frames on a shared zoomable canvas the user watches live at `https://drafted.live`. *Reusable projects + anchors = velocity with accuracy.*
13
-
14
- The point is the **compounding loop**: you don't produce in a vacuum, you draw on what the org already knows and leave it richer each pass.
15
-
16
- ## The surface is a filesystem — one tool, one path grammar
17
-
18
- There is exactly **one** tool to know: `fs`. It looks, feels, and behaves like a local filesystem, and an **org is the top folder** — you address an org, you never switch to it:
19
-
20
- ```
21
- fs(ls, path="/") → the orgs you belong to
22
- fs(ls, path="/o/<org>") → that org's wiki · skills · projects
23
- /o/<org>/wiki/<path> org knowledge pages (markdown, free nesting)
24
- /o/<org>/skills/<slug> reusable procedures (flat: one dir per skill)
25
- /o/<org>/projects/<project>/<layer>/<lane>/<file> frames on the surface (project → layer → lane → file)
26
- ```
27
-
28
- Verbs: `ls` (list) · `read` (cat, hashline-annotated) · `write` (create/overwrite) · `edit` (hashline ops) · `mv` (rename/move) · `rm` (move to archive) · `mkdir` (create a project) · `search` (grep).
29
-
30
- **A project is a folder.** It starts with **no layers** — writing to `/o/<org>/projects/<name>/<layer>/...` auto-creates the layer (mkdir -p). The agent defines the filesystem structure by writing into it. URLs are the same paths: `https://drafted.live/o/<org>/projects/<project>/<layer>/<lane>/<file>` — the URL's pathname IS the fs path (one canonical link for both humans and agents). Bare `/wiki`, `/skills`, `/projects` roots still work via the session's working org.
31
-
32
- ## The loop — prime → build → compound
33
-
34
- - **Prime (session start).** Pull accumulated value in. The system *enforces* this: you must search the wiki before working (G1), and the skills anchored to an org, folder or project must be read before you change content there (a refused write returns HTTP 428 `anchor_gate` with the rule text; read the skill, then retry). Don't fight the gates — they make you start smart.
35
- - **Build (the work).** Produce artifacts as frames on the surface.
36
- - **Compound (session end / when you notice something).** Deposit learning back: capture knowledge, distill or fix a skill, harden the project. This is *your* responsibility — the system can't force it, so do it.
37
-
38
- ## The gates you'll encounter (and how to satisfy them)
39
-
40
- These reset every session. A gate that blocks you tells you exactly what to call next — do it, don't work around it.
41
-
42
- - **G1 — wiki search before work.** Before reading or editing anything, `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant org knowledge.
43
- - **G2 — prior-art before a new skill.** Writing a new `/o/<org>/skills/<slug>` requires `fs(search, path="/o/<org>/skills", query="<topic>")` first.
44
- - **G3 — prior-art before a new project.** Creating a project requires wiki + skill searches first.
45
- - **G4 — anchored skills** gate every content write (frames, wiki pages, skills) in the scope they are anchored to: an org, a folder or a project. The refusal (HTTP 428 `anchor_gate`) carries the rule text and the `fs(read)` path of each skill; read them, then retry. Editing a skill re-arms the gate. They are how the org does this work.
46
- - **G5 — the project's anchored frames** are suggested reading (briefs, constraints, style guides). The server does not enforce them.
47
- - **G6 — a layer's rules** are surfaced when you work in that layer. Honor them.
48
-
49
- ## The commands (when to reach for each)
50
-
51
- These bookend the loop. Prime/feed at the start, deposit at the end.
52
-
53
- - `/drafted:onboard-drafted` — first run: orient + bootstrap the harness (seed wiki, starter skills, first project).
54
- - `/drafted:ingest` — bring knowledge into the wiki (a research output, existing documents, or by interrogating the user).
55
- - `/drafted:create-skill` — capture a repeatable procedure (with knowledge + prior-art search).
56
- - `/drafted:create-project` — start a project or a reusable template (with knowledge/skill/template search).
57
- - `/drafted:improve-wiki` — fix stale / wrong / fragmented knowledge.
58
- - `/drafted:improve-skill` — fix a skill that underperformed.
59
- - `/drafted:improve-project-harness` — turn corrections into enforced gates (anchors / attached skills / layer rules).
60
- - `/drafted:extract` — session-end: deposit knowledge, a skill, and/or a template (the user picks which).
61
-
62
- ## Working on the surface
63
-
64
- - **Navigate by path, exactly like a filesystem.** `fs(ls, path="/")` to see your orgs; `fs(ls, path="/o/<org>/projects")` to see a project list; `fs(ls, path="/o/<org>/projects/<project>")` for layers/lanes; `fs(ls, path="/o/<org>/projects/<project>/<layer>")` for frames. The project is resolved from the path itself — no separate "open" step.
65
- - **Create a project with `fs(mkdir, path="/o/<org>/projects/<name>")`** — or just `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/<file>", content=...)` and the project + layer auto-create in the addressed org.
66
- - **Default to the surface for substantive artifacts.** When asked to draft, write, plan, analyze, compare, design, document, summarize, report, spec, model, or make a deck/table, create or update frames instead of leaving the durable result only in chat. One visible frame per artifact or section.
67
- - **Read before editing.** `fs(read)` returns every line hashline-annotated (`1abc|<content>`); `fs(edit, ops=[{type:"replace", lineHash:"1abc", newContent:"..."}])` targets exactly that line. For partial reads, pass `lines: "2-50"` — you get back just that range, still hash-annotated, and can edit within it.
68
- - **Prefer Google Workspace when Drive is connected.** `whoami` reports it — `googleDrive.connected: true` means use `fs(write, path=".../<name>.google-doc"|".google-sheet"|".google-slide")` for docs, sheets, and decks, populated immediately with the matching native write action. False means they cannot be created at all: use `.md`/`.html` frames and don't ask the user which fallback they want.
69
- - **`fs(mv, from="/o/<org>/projects/<p>/<layer>/<lane>/<file>", to="...")`** renames or moves (cross-project too). **`fs(rm, path="/o/<org>/projects/<project>")` archives** — agents never hard-delete; the archive is in the web UI.
70
- - **Return a clickable link** for what you touched — the `frameUrl`/`projectUrl` in the fs response is the URL the user opens.
71
-
72
- ## Source material from Google (`gws`) — Drafted holds the work
73
-
74
- Local sessions may have Google's Workspace CLI, `gws`, signed in **read-only** to the user's **own** Google account (set up by the installer or `drafted google-setup`). Split the jobs: **`gws` reads what already exists** (mail, Drive files, calendar) on the user's machine with the user's token. **Drafted is where what you make from it lives, and the only way anything is sent.** Don't use Drafted as a mail store, and don't leave the result in chat.
75
-
76
- - **Check first.** `gws auth status` is ready when `has_refresh_token` is `true` and a scope contains `/auth/gmail`. Read those two fields; don't echo the whole output. If it isn't ready, suggest `! drafted google-setup` once (macOS/Linux; it's interactive, so the user runs it). Remote sessions (claude.ai, `/mcp`) have no `gws`, so skip this section there.
77
- - **Ask before you read.** The first inbox or Drive read in a session needs the user's yes and a scope ("last 30 days", "threads with Acme", "this folder"). Offer the narrowest useful one.
78
- - **Headers before bodies.** List with `gws gmail users threads list --params '{"userId":"me","q":"newer_than:30d -category:promotions -category:social","maxResults":50}'`. Get headers with `gws gmail users threads get --params '{"userId":"me","id":"<threadId>","format":"metadata","metadataHeaders":["From","Subject","Date"]}'` and triage on subject, participants and date. Open a body (`gws gmail +read --id <messageId>`) only for threads you will actually use. Drive: `gws drive files list --params '{"q":"trashed = false","orderBy":"modifiedTime desc","pageSize":50,"fields":"files(id,name,mimeType,modifiedTime,webViewLink)"}'`. When unsure of a method's shape, run `gws schema <service.resource.method>`.
79
- - **Synthesize, never copy.** A frame or wiki page carries your synthesis plus a link back to the source (`https://mail.google.com/mail/u/0/#all/<threadId>`, or the Drive file's `webViewLink`). It never carries pasted email bodies or other people's addresses. The surface is shared with collaborators; the inbox is not. Quote only what the user asks for, attributed.
80
- - **Every send goes through Drafted, and the user approves it.** `gws` is signed in read-only, and it stays that way. To send, draft the email, then `action(action="propose", type="send_email", payload='{"from":"<the user\'s own connected Gmail address>","to":"<one address>","subject":"<one line>","body":"<plain text>"}', reason="<one line: why>")`. The user reads it and approves it in the Drafted UI. Nothing is sent before that, and you cannot approve it yourself. It goes out AS the user, from their Gmail connected in Drafted Settings; if the proposal is refused because none is connected, tell them to connect it there. Never send by any other route (widening the `gws` sign-in, SMTP, another tool), even when asked to "just send it": propose, and point the user to the approval.
81
- - **Reply inside the thread.** When you answer an email, add `"threadId"` (the Gmail thread id) and `"inReplyTo"` (that message's `Message-ID` header, read with `gws gmail users messages get ... format=metadata`) to the `send_email` payload, so the reply lands in the same conversation for both sides.
82
- - **Connect the thread when a project grows out of an email.** `mail(action="connect", project="<project>", gmailThreadId="<id>", title="<your short name for it>")`, then ask the user what should happen when a reply comes in: **notify** (mark it in the project's Inbox) or **work** (their computer starts Claude Code on the reply, with only Drafted's tools, and nothing goes out without their approval). Drafted never reads the mail: the user's computer checks connected threads with their `gws` and reports only that a reply arrived. Use a title of your own, not an email subject full of personal details. After you have dealt with a reply, `mail(action="handled", id=...)` only if the user asked you to.
83
-
84
- ## Quality conventions
85
-
86
- - **Match format to layer intent.** Research/strategy/copy are usually markdown; visual work (wireframes, designs, dashboards) is HTML.
87
- - **Wireframes are low-fidelity** (grayscale, placeholders); reserve color and real content for the designs/final layer.
88
- - **Choose dimensions to fit content** — `autoSize: true` for HTML, or explicit `width`/`height`.
89
- - **Don't re-read unchanged frames** you already have this conversation.
90
- - **Diagrams: prefer native Excalidraw.** For flowcharts, process maps, architecture/system/data-flow diagrams, or visual maps, write a `.excalidraw` file (scene JSON) and load the `excalidraw-drafted` skill for the authoring guidance. Use HTML/markdown frames for web/UI mockups, rich layouts, or non-editable artifacts.
91
-
92
- ## Skill authoring
93
-
94
- - **Git is the source of truth for skills; Drafted renders, indexes, and searches.** When a repo is connected to a folder, the skills under `.agents/skills/` in that repo ARE the skills for that folder — authoring happens by committing to git, not by writing into Drafted. Drafted write paths for a repo-owned skill return `409 repo_owned` naming the repo, branch, and path to commit to. Use `repo(action="rescan")` after a push so the index reflects the change.
95
- - **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md` (the `readme` param) is optional but recommended — it is what renders for the skill directory on GitHub if the skill is ever exported; omitting it creates the skill with a warning. A skill is a directory: `fs(write, path="/o/<org>/skills/<slug>/README.md", content=...)` and any other supporting file work once the skill exists, and `fs(ls, path="/o/<org>/skills/<slug>")` lists them. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
96
- - **Knowledge goes in the wiki, procedure in skills.** Drafted owns org knowledge (wiki pages via `fs` under `/o/<org>/wiki/`); git owns reusable procedure (skills under `.agents/skills/`). Don't put a skill body in a wiki page or a knowledge doc in a SKILL.md.
97
- - **Machine-specific build output is never portable.** Build `node_modules`, downloaded browsers, compiled binaries into a `.skillinstall/` directory inside the skill — Drafted always strips it on push and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe live in git/Drafted.
98
- - **Improve skills when you find a better way.** Fix or distill a skill that underperformed rather than leaving it stale.
99
-
100
- ## Surface URL recognition
101
-
102
- Any URL containing `/f/{uuid}` is a Drafted frame link — `fs(read, path=URL)` gets its content. Canonical links are the fs paths themselves: `/o/<org>/wiki/...`, `/o/<org>/skills/<slug>`, `/o/<org>/projects/<project>/<layer>/<lane>/<file>`. Never `WebFetch` Drafted URLs — the MCP tools authenticate properly.
@@ -1,164 +0,0 @@
1
- export const UMAMI_EVENTS = Object.freeze({
2
- SIGNUP_STARTED: 'signup_started',
3
- SIGNUP_COMPLETED: 'signup_completed',
4
- ORG_CREATED: 'org_created',
5
- PROJECT_CREATED: 'project_created',
6
- PROJECT_OPENED: 'project_opened',
7
- TEMPLATE_USED: 'template_used',
8
- FRAME_CREATED: 'frame_created',
9
- FRAME_UPDATED: 'frame_updated',
10
- FRAME_DELETED: 'frame_deleted',
11
- SHARE_CREATED: 'share_created',
12
- SHARE_ACCEPTED: 'share_accepted',
13
- INVITE_SENT: 'invite_sent',
14
- MCP_CONNECTED: 'mcp_connected',
15
- MCP_TOOL_CALLED: 'mcp_tool_called',
16
- WIKI_PAGE_CREATED: 'wiki_page_created',
17
- SKILL_ATTACHED: 'skill_attached',
18
- DRIVE_CONNECTED: 'drive_connected',
19
- DRIVE_SYNC_COMPLETED: 'drive_sync_completed',
20
- DRAFTED_INSTALL: 'drafted_install',
21
- DRAFTED_UPDATE: 'drafted_update',
22
- DRAFTED_HEARTBEAT: 'drafted_heartbeat',
23
- DRAFTED_MCP_CONFIGURED: 'drafted_mcp_configured',
24
- DRAFTED_MCP_REQUEST: 'drafted_mcp_request',
25
- DRAFTED_MCP_ERROR: 'drafted_mcp_error',
26
- DRAFTED_UPDATE_HELPER_STARTED: 'drafted_update_helper_started',
27
- DRAFTED_UPDATE_HELPER_FAILED: 'drafted_update_helper_failed',
28
- CTA_CLICKED: 'cta_clicked',
29
- TEMPLATE_SELECTED: 'template_selected',
30
- PROJECT_NAVIGATED: 'project_navigated',
31
- SHARE_MODAL_OPENED: 'share_modal_opened',
32
- SHARE_MODAL_ACTION: 'share_modal_action',
33
- SURFACE_LOADED: 'surface_loaded',
34
- SIDEBAR_USED: 'sidebar_used',
35
- TOOL_PANEL_USED: 'tool_panel_used',
36
- });
37
-
38
- const BLOCKED_KEYS = new Set([
39
- 'email', 'userEmail', 'name', 'userName', 'orgName', 'projectName', 'label', 'title',
40
- 'content', 'html', 'markdown', 'body', 'prompt', 'token', 'cookie', 'authorization',
41
- 'password', 'secret', 'apiKey', 'request', 'headers', 'file', 'buffer', 'dataUrl',
42
- ]);
43
- const ID_KEYS = new Set(['userId', 'orgId', 'projectId', 'projectSlug', 'frameId', 'shareId', 'skillId', 'templateId', 'templateSlug', 'userRole', 'role', 'buildId', 'pageType', 'tool', 'action', 'source', 'layer', 'lane', 'mode', 'installId', 'schemaVersion', 'installerVersion', 'cliVersion', 'osFamily', 'osVersion', 'arch', 'nodeVersion', 'npmVersion', 'claudeDesktop', 'claudeCode', 'codex', 'cursor', 'updateHelperStatus', 'mcpMode', 'errorCode', 'matchKey']);
44
-
45
- export function getUmamiConfig(config = {}) {
46
- const hostUrl = (config.umamiHostUrl || process.env.UMAMI_HOST_URL || '').replace(/\/$/, '');
47
- const websiteId = config.umamiWebsiteId || process.env.UMAMI_WEBSITE_ID || '';
48
- return { hostUrl, websiteId, enabled: Boolean(hostUrl && websiteId) };
49
- }
50
-
51
- export function sanitizeUmamiEventData(data = {}) {
52
- const output = {};
53
- if (!data || typeof data !== 'object' || Array.isArray(data)) return output;
54
- for (const [key, value] of Object.entries(data)) {
55
- if (BLOCKED_KEYS.has(key)) continue;
56
- if (value === undefined || value === null) continue;
57
- if (typeof value === 'object') continue;
58
- if (!ID_KEYS.has(key) && /email|name|content|html|markdown|body|prompt|token|cookie|auth|secret|password|key|file/i.test(key)) continue;
59
- if (typeof value === 'string') {
60
- if (value.length > 120) continue;
61
- if (/@/.test(value)) continue;
62
- output[key] = value;
63
- } else if (typeof value === 'number' || typeof value === 'boolean') {
64
- output[key] = value;
65
- }
66
- }
67
- return output;
68
- }
69
-
70
- export function getUmamiHead(config = {}) {
71
- const umami = getUmamiConfig(config);
72
- if (!umami.enabled) return '';
73
- const ctx = sanitizeUmamiEventData({
74
- userId: config.userId,
75
- orgId: config.orgId,
76
- projectId: config.projectId,
77
- projectSlug: config.projectSlug,
78
- userRole: config.userRole,
79
- buildId: config.buildId,
80
- pageType: config.pageType,
81
- });
82
- return `<script defer src="${umami.hostUrl}/script.js" data-website-id="${umami.websiteId}"></script>
83
- <script>
84
- window.__DRAFTED_ANALYTICS__ = ${JSON.stringify(ctx)};
85
- window.draftedTrack = function(name, data) {
86
- var base = window.__DRAFTED_ANALYTICS__ || {};
87
- var payload = Object.assign({}, base, data || {});
88
- if (window.umami && typeof window.umami.track === 'function') window.umami.track(name, payload);
89
- };
90
- window.addEventListener('load', function() {
91
- var c = window.__DRAFTED_ANALYTICS__ || {};
92
- if (c.userId && window.umami && typeof window.umami.identify === 'function') window.umami.identify(c.userId, { orgId: c.orgId, role: c.userRole });
93
- });
94
- </script>`;
95
- }
96
-
97
- export function getUmamiInteractionScript(pageType) {
98
- return `<script>
99
- (function() {
100
- function track(name, data) { if (window.draftedTrack) window.draftedTrack(name, Object.assign({ pageType: ${JSON.stringify(pageType)} }, data || {})); }
101
- window.addEventListener('load', function() {
102
- if (${JSON.stringify(pageType)} === 'surface') track('surface_loaded');
103
- });
104
- document.addEventListener('click', function(event) {
105
- var el = event.target && event.target.closest && event.target.closest('a,button,[data-analytics-event],[data-template-slug],[data-project-id]');
106
- if (!el) return;
107
- var eventName = el.getAttribute('data-analytics-event');
108
- if (eventName) return track(eventName, { action: el.getAttribute('data-analytics-action') || undefined });
109
- var href = el.getAttribute('href') || '';
110
- var cls = el.className || '';
111
- if (el.matches('.nav-cta,.btn-primary,.btn-secondary')) track('cta_clicked', { action: href || (el.textContent || '').trim().slice(0, 40) });
112
- if (el.getAttribute('data-template-slug')) track('template_selected', { templateSlug: el.getAttribute('data-template-slug') });
113
- if (el.getAttribute('data-project-id')) track('project_navigated', { projectId: el.getAttribute('data-project-id') });
114
- if (/share/i.test(String(cls)) || /share/i.test(el.textContent || '')) track('share_modal_action');
115
- if (/sidebar/i.test(String(cls))) track('sidebar_used');
116
- if (/tool|panel/i.test(String(cls))) track('tool_panel_used');
117
- }, true);
118
- })();
119
- </script>`;
120
- }
121
-
122
- function getRequestUrl(req) {
123
- if (!req) return undefined;
124
- const host = typeof req.get === 'function' ? req.get('host') : req.headers?.host;
125
- const protocol = req.protocol || 'https';
126
- return host ? `${protocol}://${host}${req.originalUrl || req.url || ''}` : undefined;
127
- }
128
-
129
- export function trackUmamiEvent(name, data = {}, req = null) {
130
- const umami = getUmamiConfig();
131
- if (!umami.enabled || !name || name.length > 50) return;
132
- // Account de-dupe: when the event carries a userId, send it as Umami's distinct id
133
- // (`payload.id`). Umami then derives sessionId = uuid(websiteId, userId) with no IP/UA/date
134
- // salt, so server-side events land in the SAME canonical session as the browser's
135
- // umami.identify(userId) call — collapsing each account to one session across devices/time.
136
- const distinctId = (typeof data.userId === 'string' && data.userId.length <= 64) ? data.userId : undefined;
137
- const payload = {
138
- type: 'event',
139
- payload: {
140
- website: umami.websiteId,
141
- name,
142
- ...(distinctId ? { id: distinctId } : {}),
143
- url: getRequestUrl(req) || process.env.BASE_URL || 'https://drafted.live',
144
- hostname: req?.hostname || 'drafted.live',
145
- language: req?.headers?.['accept-language'],
146
- referrer: req?.headers?.referer,
147
- data: sanitizeUmamiEventData(data),
148
- },
149
- };
150
- const controller = new AbortController();
151
- const timeout = setTimeout(() => controller.abort(), 1500);
152
- fetch(`${umami.hostUrl}/api/send`, {
153
- method: 'POST',
154
- headers: {
155
- 'Content-Type': 'application/json',
156
- 'User-Agent': req?.headers?.['user-agent'] || 'Drafted Server Analytics',
157
- // Not req.ip: with trust proxy it is the client-chosen leftmost XFF entry. This module
158
- // ships in the npm package, so it cannot import the server's clientIp().
159
- 'X-Forwarded-For': req?.headers?.['cf-connecting-ip'] || req?.socket?.remoteAddress || '',
160
- },
161
- body: JSON.stringify(payload),
162
- signal: controller.signal,
163
- }).catch(() => {}).finally(() => clearTimeout(timeout));
164
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * Anchored skills: which scope does an fs-style path address?
3
- *
4
- * Pure, no I/O. The root keyword is the separator (see folder-path.mjs), so the
5
- * folder chain and the gate surface both fall out of one split:
6
- *
7
- * /o/acme/eng/backend/projects/api/designs/default/x.html
8
- * -> surface 'projects', folder 'eng/backend', project 'api'
9
- * /wiki/eng/foo -> surface 'wiki', folder '' (the ORG wiki)
10
- * /eng/wiki/foo -> surface 'wiki', folder 'eng' (the folder's wiki)
11
- *
12
- * Gate surfaces follow where the write lands: wiki -> wiki_pages, skills ->
13
- * skills, projects AND tasks -> designs rows ('projects'; a task is a frame).
14
- * A path that names no root (`/`, `/eng`) is a folder, not content: surface null.
15
- *
16
- * Id-addressed routes (layout, conflicts, designs/:id) cannot be classified
17
- * from a path; they resolve id -> project -> projects.folder and then call
18
- * buildScopeChain themselves.
19
- */
20
- import { ROOTS, folderPathError, normalizeFolder, splitFolderScope } from './folder-path.mjs';
21
-
22
- const SURFACE_OF_ROOT = { wiki: 'wiki', skills: 'skills', tasks: 'projects', projects: 'projects' };
23
-
24
- /**
25
- * @returns {{ org: string|null, folder: string, root: string|null, surface: 'projects'|'wiki'|'skills'|null,
26
- * project: string|null, rest: string[], error?: string }}
27
- * `project` is the NAME in the path (null for tasks and bare `/projects`): the
28
- * caller resolves it to an id. `rest` is what follows the project (projects) or
29
- * the root (wiki/skills/tasks).
30
- */
31
- export function classifyPath(path) {
32
- let segs = String(path || '').split('/').map((s) => s.trim()).filter(Boolean);
33
- let org = null;
34
- if (segs[0] === 'o' && segs.length >= 2) {
35
- org = segs[1];
36
- segs = segs.slice(2);
37
- }
38
- const { folder, rest } = splitFolderScope('/' + segs.join('/'));
39
- const out = { org, folder: normalizeFolder(folder), root: null, surface: null, project: null, rest: [] };
40
- if (out.folder) {
41
- const bad = folderPathError(out.folder);
42
- if (bad) return { ...out, error: bad };
43
- }
44
- if (!rest) return out;
45
- const [root, ...tail] = rest.split('/').filter(Boolean);
46
- out.root = root;
47
- out.surface = SURFACE_OF_ROOT[root];
48
- if (root === 'projects') {
49
- out.project = tail[0] || null;
50
- out.rest = tail.slice(1);
51
- } else {
52
- out.rest = tail;
53
- }
54
- return out;
55
- }
56
-
57
- /**
58
- * Both ends of a move. A `to` that names no root is relative to the source
59
- * (liftFolderScope does the same), so it gates the same scope. Identical
60
- * targets collapse to one; a cross-project move yields two.
61
- */
62
- export function classifyMove(from, to) {
63
- const src = classifyPath(from);
64
- const dstRaw = to == null ? null : classifyPath(to);
65
- const dst = dstRaw && dstRaw.root ? dstRaw : null;
66
- const targets = [src];
67
- if (dst && !sameTarget(src, dst)) targets.push(dst);
68
- return { from: src, to: dst, targets };
69
- }
70
-
71
- function sameTarget(a, b) {
72
- return a.surface === b.surface && a.folder === b.folder && a.project === b.project;
73
- }
74
-
75
- /**
76
- * The scope chain a write at (folder, projectId) sits inside, outermost first:
77
- * org, each folder prefix, then the project. 'eng/backend' -> org, eng, eng/backend.
78
- *
79
- * @returns {Array<{scope:'org'|'folder'|'project', folder?:string, projectId?:string}>}
80
- */
81
- export function buildScopeChain({ folder = '', projectId = null } = {}) {
82
- const chain = [{ scope: 'org', folder: '' }];
83
- const segs = normalizeFolder(folder).split('/').filter(Boolean);
84
- for (let i = 0; i < segs.length; i++) {
85
- chain.push({ scope: 'folder', folder: segs.slice(0, i + 1).join('/') });
86
- }
87
- if (projectId) chain.push({ scope: 'project', projectId });
88
- return chain;
89
- }
90
-
91
- /** Human/receipt form, stored in skill_gate_events.scope_chain. */
92
- export function formatScopeChain(chain) {
93
- return (chain || [])
94
- .map((n) => (n.scope === 'org' ? 'org' : n.scope === 'folder' ? n.folder : `project:${n.projectId}`))
95
- .join(' > ');
96
- }
97
-
98
- export { ROOTS };
@@ -1,94 +0,0 @@
1
- export const EXCALIDRAW_MIME = 'application/vnd.excalidraw+json';
2
-
3
- // Linear elements (arrow/line/freedraw) REQUIRE a `points` array — Excalidraw's scene
4
- // restore reads points.length and crashes ("Cannot read properties of undefined (reading
5
- // 'length')") → eternal "Loading scene" spinner. Hand-authored/agent-written scenes
6
- // routinely omit it (source: "https://excalidraw.com", ids like "el1"), so repair it
7
- // here: every read (serve) and write path funnels through normalizeExcalidrawScene, so
8
- // existing broken frames open and new bad writes are fixed before they land.
9
- const LINEAR_ELEMENT_TYPES = new Set(['arrow', 'line', 'freedraw']);
10
-
11
- function repairLinearElement(el) {
12
- if (!LINEAR_ELEMENT_TYPES.has(el.type)) return el;
13
- const pts = el.points;
14
- const valid = Array.isArray(pts) && pts.length > 0 &&
15
- pts.every((p) => Array.isArray(p) && p.length >= 2 && typeof p[0] === 'number' && typeof p[1] === 'number');
16
- if (valid) return el;
17
- const w = typeof el.width === 'number' && el.width > 0 ? el.width : 1;
18
- const h = typeof el.height === 'number' && el.height > 0 ? el.height : 0;
19
- return { ...el, points: [[0, 0], [w, h]] };
20
- }
21
-
22
- export function emptyExcalidrawScene() {
23
- return {
24
- type: 'excalidraw',
25
- version: 2,
26
- source: 'https://drafted.live',
27
- elements: [],
28
- appState: {},
29
- files: {},
30
- };
31
- }
32
-
33
- export function normalizeExcalidrawScene(input) {
34
- const scene = typeof input === 'string' ? JSON.parse(input) : input;
35
- if (!scene || typeof scene !== 'object' || Array.isArray(scene)) {
36
- throw new Error('Excalidraw data must be an object');
37
- }
38
- if (!Array.isArray(scene.elements)) {
39
- throw new Error('Excalidraw data must include elements array');
40
- }
41
- const appState = scene.appState && typeof scene.appState === 'object' && !Array.isArray(scene.appState)
42
- ? scene.appState
43
- : {};
44
- const files = scene.files && typeof scene.files === 'object' && !Array.isArray(scene.files)
45
- ? scene.files
46
- : {};
47
- return {
48
- type: scene.type || 'excalidraw',
49
- version: scene.version || 2,
50
- source: scene.source || 'https://drafted.live',
51
- elements: scene.elements.map(repairLinearElement).filter(Boolean),
52
- appState,
53
- files,
54
- };
55
- }
56
-
57
- export function stringifyExcalidrawScene(input) {
58
- return JSON.stringify(normalizeExcalidrawScene(input), null, 2);
59
- }
60
-
61
- // Element-level merge for surgical edits: shallow-merge `upserts` ONTO the
62
- // existing element with the same id (so a partial {id,x,y} moves it without
63
- // dropping its other props), and drop every id in `removeIds`. Existing element
64
- // order is preserved (z-order = array order); updated elements keep their
65
- // position. This is the accumulate/merge path that write_excalidraw (full
66
- // replace) is not — it lets a large scene be edited with a small payload
67
- // instead of resending the whole thing.
68
- //
69
- // An id that does NOT exist is an ERROR, not an append. The silent-append shape
70
- // shipped first and bit an agent: a typo'd/changed id added a partial
71
- // `{id,strokeStyle,…}` object with no type/x/y beside the untouched original, so
72
- // the render kept showing the old element and the write reported ok. Adding
73
- // elements is a full-scene job (write_excalidraw); edit is for existing ones.
74
- export function mergeExcalidrawElements(existing, upserts = [], removeIds = []) {
75
- const scene = normalizeExcalidrawScene(existing);
76
- const removeSet = new Set(Array.isArray(removeIds) ? removeIds : []);
77
- const byId = new Map();
78
- for (const el of scene.elements) {
79
- if (!el || removeSet.has(el.id)) continue;
80
- byId.set(el.id, el);
81
- }
82
- for (const el of (Array.isArray(upserts) ? upserts : [])) {
83
- if (!el || typeof el !== 'object' || Array.isArray(el) || !el.id) {
84
- throw new Error('Each element to upsert must be an object with an id');
85
- }
86
- if (removeSet.has(el.id)) continue;
87
- const prev = byId.get(el.id);
88
- if (!prev) {
89
- throw new Error(`No element with id "${el.id}" in this scene — element-level edit updates existing elements; write the whole scene (write_excalidraw) to add or replace one.`);
90
- }
91
- byId.set(el.id, { ...prev, ...el });
92
- }
93
- return { ...scene, elements: Array.from(byId.values()) };
94
- }
@@ -1,176 +0,0 @@
1
- /**
2
- * Folder is the single recursive container: the org is that node at depth 0, and
3
- * EVERY folder level carries the same four roots (wiki, skills, tasks, projects).
4
- *
5
- * /o/<org>/ folder (depth 0)
6
- * wiki/ skills/ tasks/ projects/
7
- * engineering/ folder
8
- * wiki/ skills/ tasks/ projects/
9
- *
10
- * The grammar is unambiguous by construction: the ROOT KEYWORD IS THE SEPARATOR.
11
- * Everything before the first `wiki`/`skills`/`tasks`/`projects` segment is the
12
- * folder chain; everything after is the path within that root. So
13
- * `/wiki/engineering/foo` is the org wiki's nested page and
14
- * `/engineering/wiki/foo` is the `engineering` folder's — distinct strings, no
15
- * collision. The one constraint this imposes: the four names are RESERVED and a
16
- * folder may not take one.
17
- *
18
- * A folder is ADDRESSED, never remembered: the chain travels in the path (and
19
- * from there in a request-local `X-Drafted-Folder` header), so no shared row
20
- * carries a "current folder". A cursor here would be the retired
21
- * `sessions.org_id` mistake rebuilt one level down — see the Drafted wiki page
22
- * `engineering/org-addressing-model`.
23
- */
24
-
25
- export const ROOTS = ['wiki', 'skills', 'tasks', 'projects'];
26
- const RESERVED = new Set(ROOTS);
27
-
28
- export function isReservedFolderName(name) {
29
- return RESERVED.has(String(name || '').trim().toLowerCase());
30
- }
31
-
32
- /** '' (org root) or 'a/b' — no leading/trailing slashes, no empty segments. */
33
- export function normalizeFolder(raw) {
34
- return String(raw || '')
35
- .split('/')
36
- .map((s) => s.trim())
37
- .filter(Boolean)
38
- .join('/');
39
- }
40
-
41
- /** null when the folder path is usable, else the reason it is not. */
42
- export function folderPathError(raw) {
43
- const segs = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
44
- if (!segs.length) return 'Folder name is required';
45
- for (const s of segs) {
46
- if (isReservedFolderName(s)) {
47
- return `"${s}" is a reserved name — wiki, skills, tasks and projects are the four roots every folder carries, so a folder cannot be called one`;
48
- }
49
- if (s.length > 120) return `Folder name too long: "${s.slice(0, 40)}…"`;
50
- }
51
- return null;
52
- }
53
-
54
- /**
55
- * Split a path into its folder chain and the root-relative remainder.
56
- *
57
- * /engineering/backend/wiki/foo → { folder: 'engineering/backend', rest: '/wiki/foo' }
58
- * /wiki/engineering/foo → { folder: '', rest: '/wiki/engineering/foo' }
59
- * /engineering → { folder: 'engineering', rest: '' }
60
- * / → { folder: '', rest: '' }
61
- *
62
- * `rest` is '' when the path names a folder itself (no root keyword in it).
63
- */
64
- export function splitFolderScope(path) {
65
- const segs = String(path || '').split('/').filter(Boolean);
66
- const at = segs.findIndex((s) => RESERVED.has(s));
67
- if (at === -1) return { folder: segs.join('/'), rest: '' };
68
- return { folder: segs.slice(0, at).join('/'), rest: '/' + segs.slice(at).join('/') };
69
- }
70
-
71
- /** Re-join a folder chain and a root-relative path into one addressable path. */
72
- export function joinFolderScope(folder, rest) {
73
- const f = normalizeFolder(folder);
74
- const r = String(rest || '').replace(/^\/+/, '');
75
- return '/' + [f, r].filter(Boolean).join('/');
76
- }
77
-
78
- /** The four roots as ls entries, for a folder at any depth (base = '/o/<org>[/<folder>]'). */
79
- export function rootEntries(base) {
80
- const b = String(base || '').replace(/\/+$/, '');
81
- const hints = {
82
- wiki: 'knowledge pages (markdown, OKF)',
83
- skills: 'reusable procedures (flat: one dir per skill slug)',
84
- tasks: 'work items — a task is a frame; status/assignee are front matter on read',
85
- projects: '<project>/<layer>/<lane>/<file>',
86
- };
87
- return ROOTS.map((name) => ({ name, type: 'directory', path: `${b}/${name}`, hint: hints[name] }));
88
- }
89
-
90
- /**
91
- * The folder lift for an fs call: pull the folder chain off the source path (and
92
- * off the destination, for mv), leaving root-relative paths the per-root
93
- * dispatch can use unchanged at any depth.
94
- *
95
- * A move stays within one folder — silently re-homing a page across folders on
96
- * a rename is the kind of quiet reorganisation that loses work.
97
- */
98
- export function liftFolderScope(path, to) {
99
- const src = splitFolderScope(path);
100
- const folder = src.folder;
101
- if (folder) {
102
- const bad = folderPathError(folder);
103
- if (bad) return { error: bad };
104
- }
105
- const out = { folder, path: src.rest || path, rest: src.rest, to };
106
- if (to) {
107
- const dst = splitFolderScope(to);
108
- if (dst.rest) {
109
- if (normalizeFolder(dst.folder) !== normalizeFolder(folder)) {
110
- return { error: `stays within one folder — source is /${folder || ''} and destination is /${dst.folder || ''}` };
111
- }
112
- out.to = dst.rest;
113
- }
114
- }
115
- return out;
116
- }
117
-
118
- /**
119
- * The folders nested DIRECTLY inside `prefix` ('' = the org itself), given the
120
- * org's folder names (each of which is its full path). One segment deeper only —
121
- * `ls` shows a level, not a tree.
122
- */
123
- /** `repoOf(fullFolderName)` -> "slug@branch" when a repo is linked to that
124
- * folder, else null.
125
- *
126
- * SAY WHAT IS OWNED, NOT "THE FOLDER". A link makes the folder's WIKI and
127
- * SKILLS git-owned — those answer 409 repo_owned on write. Its TASKS and
128
- * PROJECTS are untouched and write normally (verified: a frame write into a
129
- * project inside a linked folder succeeds). A bare "GIT-OWNED" on the folder
130
- * overclaims, and an agent that believes it will not attempt a frame write it
131
- * is perfectly entitled to make — a false negative costs more here than the
132
- * 409 it was meant to prevent. */
133
- export function subfolderEntries(base, prefix, names, repoOf = null) {
134
- const depth = prefix ? normalizeFolder(prefix).split('/').length : 0;
135
- const kids = new Set();
136
- for (const raw of names || []) {
137
- const name = normalizeFolder(raw);
138
- if (!name) continue;
139
- if (prefix && !name.startsWith(normalizeFolder(prefix) + '/')) continue;
140
- const segs = name.split('/');
141
- if (segs.length > depth) kids.add(segs[depth]);
142
- }
143
- const full = (name) => normalizeFolder([prefix, name].filter(Boolean).join('/'));
144
- return [...kids].sort().map((name) => {
145
- const repo = repoOf ? repoOf(full(name)) : null;
146
- return {
147
- name,
148
- type: 'directory',
149
- path: `${String(base || '').replace(/\/+$/, '')}/${name}`,
150
- hint: repo
151
- ? `folder — carries its own wiki, skills, tasks and projects · wiki + skills GIT-OWNED by ${repo} (edit those via commit); tasks + projects write normally`
152
- : 'folder — carries its own wiki, skills, tasks and projects',
153
- };
154
- });
155
- }
156
-
157
- /**
158
- * Process surfaces: wiki pages agents WRITE through the tool rather than read.
159
- * Promote-to-commons appends to the wiki `log`, and a promotion that
160
- * contradicts canon is flagged in `review`.
161
- *
162
- * They must be excluded from the seed AND exempted from the repo_owned guard,
163
- * and those two must agree. Excluded but not exempted, they end up unauthorable
164
- * AND unwritten — the tool 409s a page git never received, and promote-to-commons
165
- * breaks with no obvious cause. Lives here because the CLI seeder and the server
166
- * guard both need it and drift between them is exactly the failure.
167
- *
168
- * Whole path segments only — `reference/log-shipping` is a normal page.
169
- */
170
- export const PROCESS_SURFACES = new Set(['log', 'review']);
171
-
172
- export function isProcessSurface(path) {
173
- return String(path || '')
174
- .split('/')
175
- .some((seg) => PROCESS_SURFACES.has(seg));
176
- }