drafted 1.18.1 → 1.18.3

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.
@@ -0,0 +1,172 @@
1
+ // Regression check for the MCP org-ambiguity policy (DRAFT-36 "one rule").
2
+ // Run: `node mcp/test-org-guards.mjs`. No framework — asserts the single pure
3
+ // decision core that the org guard delegates to, for BOTH creates and forks.
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin } from './server.mjs';
9
+ import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
10
+
11
+ // One rule governs create AND fork (a fork is a create). A write proceeds when its
12
+ // org is a real root — explicit org=, a bound/active project, or a single-org user's
13
+ // only org. It refuses to GUESS only when the user is multi-org with nothing bound.
14
+ assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
15
+ assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
16
+ assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
17
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
18
+
19
+ // A remote session is NOT a root. Its session org is the org the connection INHERITED
20
+ // (the user's default), not one anybody chose — that inheritance wrote a Beoflow-bound
21
+ // agent's wiki page into Personal. Multi-org remote must name its org like anyone else;
22
+ // single-org remote stays frictionless (covered by the orgCount:1 case above).
23
+ assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), true, 'remote + multi-org → BLOCK (its session org is inherited, not chosen)');
24
+ assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 1 }), false, 'remote + single org → allow');
25
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
26
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
27
+
28
+ // The xcode-build incident state: multi-org + a bound project sticky from an
29
+ // earlier, unrelated project(open). Under Reading A a fork is NOT hard-blocked —
30
+ // it lands in the active project's org (a real root) and the response RECEIPT names
31
+ // that org, so the fork is visible instead of silent. Create and fork agree here by
32
+ // design (one rule); the fix for the surprise is the receipt, not a block.
33
+ const incident = { orgCount: 3, boundOrgId: 'causeway', activeProjectId: 'p1' };
34
+ assert.equal(projectlessMutationNeedsOrg(incident), false, 'incident state: fork allowed into the bound org — awareness comes from the receipt (Reading A)');
35
+
36
+ // The genuinely ambiguous case still errors for a fork, exactly as for a create:
37
+ assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org fork with nothing bound → BLOCK before any copy is created');
38
+
39
+ // ── Persisted stdio state must not cross DATABASES ────────────────────────────
40
+ // The 2026-07-31 incident: this repo registers a prod stdio MCP (drafted.live) and a
41
+ // local-dev one (localhost:3477) that run in the SAME cwd. Keyed by cwd alone, the
42
+ // local MCP's activeProject + boundOrgId were rehydrated by the prod MCP at boot, so
43
+ // the prod session addressed every request to an org id that exists only in the local
44
+ // dev DB: get_org reported `workingOrg {id: <local-uuid>, name: null}` in no membership
45
+ // list, and project-less calls failed `not a member of org "<local-uuid>"`.
46
+ const file = join(mkdtempSync(join(tmpdir(), 'drafted-state-')), 'mcp-state.json');
47
+ const cwd = '/Users/x/GitHub/drafted.live';
48
+ const PROD = 'https://drafted.live';
49
+ const LOCAL = 'http://localhost:3477';
50
+
51
+ savePersistedProject(
52
+ { activeProjectId: 'local-project', boundOrgId: 'b111302a-local-db-only' },
53
+ { file, cwd, serverUrl: LOCAL }
54
+ );
55
+ assert.equal(
56
+ loadPersistedProject({ file, cwd, serverUrl: PROD }),
57
+ null,
58
+ 'prod MCP must NOT rehydrate the local dev MCP state saved in the same cwd'
59
+ );
60
+ assert.equal(
61
+ loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.boundOrgId,
62
+ 'b111302a-local-db-only',
63
+ 'same server + same cwd still rehydrates (the feature this persistence exists for)'
64
+ );
65
+
66
+ savePersistedProject({ activeProjectId: 'prod-project', boundOrgId: 'org-prod' }, { file, cwd, serverUrl: PROD });
67
+ assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project');
68
+ assert.equal(
69
+ loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.activeProjectId,
70
+ 'local-project',
71
+ 'the two servers keep independent entries for one cwd'
72
+ );
73
+
74
+ // A pre-fix (bare-cwd) entry carries no record of which server minted its ids, so it is
75
+ // never adopted — that entry IS the poisoned state.
76
+ savePersistedProject({ activeProjectId: 'legacy', boundOrgId: 'b111302a-local-db-only' }, { file, cwd });
77
+ assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project',
78
+ 'a legacy un-namespaced entry never leaks into a server-scoped read');
79
+
80
+ // ── A non-member working org can never stay the silent default ────────────────
81
+ const stale = 'b111302a-937c-489b-8cec-a422c853ce85';
82
+ assert.equal(
83
+ boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale }),
84
+ true,
85
+ 'server rejected the org WE addressed the request to → drop it and retry unaddressed'
86
+ );
87
+ assert.equal(
88
+ boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale, hasExplicitOrg: true }),
89
+ false,
90
+ 'an explicit org= is the CALLER\'s address — surface the error, never silently drop it'
91
+ );
92
+ assert.equal(
93
+ boundOrgRejected({ message: 'not a member of org "Drafted"', boundOrgId: stale }),
94
+ false,
95
+ 'a rejection naming a DIFFERENT org is not ours to heal'
96
+ );
97
+ assert.equal(boundOrgRejected({ message: 'Project not found', boundOrgId: stale }), false, 'unrelated error → no heal');
98
+ assert.equal(boundOrgRejected({ message: `not a member of org "${stale}"` }), false, 'nothing bound → nothing to drop');
99
+
100
+ // ── receiptOrg: the mutation receipt must name where the write LANDED ─────────
101
+ // Regression for the sibling of the foreign-org bug: a UUID-addressed page
102
+ // self-derives its org server-side, so echoing the session's working org made
103
+ // `org:` disagree with the (correct) `url:` on a cross-org edit.
104
+ {
105
+ const ORGS = [
106
+ { id: 'org-a', name: 'Alpha' },
107
+ { id: 'org-b', name: 'Bravo' },
108
+ ];
109
+ const SESSION = { id: 'org-a', name: 'Alpha' };
110
+
111
+ // resource lives in the session's own org → session context, unchanged
112
+ assert.deepEqual(
113
+ receiptOrg({ resourceOrgId: 'org-a', sessionOrg: SESSION, orgList: ORGS }),
114
+ SESSION,
115
+ 'same-org write should echo the session org',
116
+ );
117
+
118
+ // response carries no org (path-addressed) → fall back to session context
119
+ assert.deepEqual(
120
+ receiptOrg({ resourceOrgId: null, sessionOrg: SESSION, orgList: ORGS }),
121
+ SESSION,
122
+ 'no resource org should fall back to the session org',
123
+ );
124
+
125
+ // THE BUG: resource lives elsewhere → must name the resource's org, not the session's
126
+ assert.deepEqual(
127
+ receiptOrg({ resourceOrgId: 'org-b', sessionOrg: SESSION, orgList: ORGS }),
128
+ { id: 'org-b', name: 'Bravo' },
129
+ 'cross-org write must echo the org the write landed in',
130
+ );
131
+
132
+ // resource org not in the membership list → still name it, honestly, rather
133
+ // than silently substituting the session org
134
+ assert.deepEqual(
135
+ receiptOrg({ resourceOrgId: 'org-z', sessionOrg: SESSION, orgList: ORGS }),
136
+ { id: 'org-z', name: null },
137
+ 'unknown resource org should be named with a null name, not swapped out',
138
+ );
139
+
140
+ // no session org at all (unbound) and a resource org present
141
+ assert.deepEqual(
142
+ receiptOrg({ resourceOrgId: 'org-b', sessionOrg: null, orgList: ORGS }),
143
+ { id: 'org-b', name: 'Bravo' },
144
+ 'unbound session should still name the resource org',
145
+ );
146
+ }
147
+
148
+ // Shape A grammar: org is the top folder of the filesystem (/o/<org>/<root>/...).
149
+ // The org segment is stripped for the root handlers and carried as the per-request
150
+ // scope; bare roots stay accepted (backward compat, session working org).
151
+ assert.deepEqual(splitOrgScope('/o/acme/wiki/engineering/authz.md'), { path: '/wiki/engineering/authz.md', org: 'acme' }, 'org-scoped wiki path strips to the bare root + org');
152
+ assert.deepEqual(splitOrgScope('/o/acme/projects/design/beoflow/wireframes/x.html'), { path: '/projects/design/beoflow/wireframes/x.html', org: 'acme' }, 'org-scoped project path strips to the bare root + org');
153
+ assert.deepEqual(splitOrgScope('/o/acme'), { path: '/', org: 'acme' }, 'ls /o/<org> lists that org\'s roots');
154
+ assert.deepEqual(splitOrgScope('/o/acme/'), { path: '/', org: 'acme' }, 'trailing slash on the org root is harmless');
155
+ assert.deepEqual(splitOrgScope('/o/acme%20brand/skills'), { path: '/skills', org: 'acme brand' }, 'org segment is URL-decoded');
156
+ assert.deepEqual(splitOrgScope('/wiki/engineering'), { path: '/wiki/engineering', org: null }, 'bare root paths pass through untouched');
157
+ assert.deepEqual(splitOrgScope('/projects/x/y/z.html'), { path: '/projects/x/y/z.html', org: null }, 'bare project paths pass through untouched');
158
+ assert.ok(splitOrgScope('/o/').error, 'a bare /o/ is an invalid org-scoped path');
159
+ assert.ok(splitOrgScope('/o/').error?.includes('expected /o/<org>/<root>'), 'error names the expected grammar');
160
+
161
+ // Full share URLs (Q2): the URL's pathname IS the fs path — stripping the origin must
162
+ // leave an addressable path, and non-URL inputs pass through untouched.
163
+ assert.equal(stripUrlOrigin('https://drafted.live/o/acme/wiki/engineering/authz.md'), '/o/acme/wiki/engineering/authz.md', 'full URL strips to its pathname');
164
+ assert.equal(stripUrlOrigin('https://drafted.live/o/acme/projects/beoflow/designs/pricing/hero.html?x=1'), '/o/acme/projects/beoflow/designs/pricing/hero.html', 'URL query params are dropped with the origin');
165
+ assert.equal(stripUrlOrigin('/o/acme/wiki/x'), '/o/acme/wiki/x', 'plain paths pass through untouched');
166
+ assert.equal(stripUrlOrigin('not a url'), 'not a url', 'non-URL input passes through');
167
+ assert.equal(stripUrlOrigin('/f/00000000-0000-0000-0000-000000000000'), '/f/00000000-0000-0000-0000-000000000000', '/f/ frame links pass through');
168
+
169
+ console.log('org-guard policy OK');
170
+ // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
171
+ // loop that keeps the event loop alive. Assertions are done — exit deterministically.
172
+ process.exit(0);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Regression for the `ls /projects` shape: the old response returned raw
3
+ // /api/projects rows (~2.4KB each once `layers` is pretty-printed), so 180
4
+ // projects blew the 90KB tool-result cap and truncated mid-JSON at 38.
5
+ // These assert the two properties that keep that from recurring: a bounded,
6
+ // line-per-project shape, and recency ordering so truncation drops the dead
7
+ // tail rather than the live projects.
8
+ //
9
+ // Run: node mcp/test-project-index.mjs
10
+ import assert from 'node:assert/strict';
11
+ import { formatProjectIndex, projectPath } from './gates.mjs';
12
+
13
+ const NOW = Date.UTC(2026, 7, 31);
14
+ const day = 86400000;
15
+
16
+ const mk = (n, over = {}) => ({
17
+ id: `id-${n}`,
18
+ name: `Project ${n}`,
19
+ slug: `project-${n}`,
20
+ orgSlug: 'acme',
21
+ folder: null,
22
+ frameCount: 3,
23
+ createdAt: new Date(NOW - 400 * day).toISOString(),
24
+ updatedAt: new Date(NOW - n * day).toISOString(),
25
+ ...over,
26
+ });
27
+
28
+ // ── path shape ───────────────────────────────────────────────────────────────
29
+ assert.equal(projectPath(mk(1)), '/o/acme/projects/project-1');
30
+ assert.equal(projectPath(mk(1, { folder: 'clients' })), '/o/acme/projects/clients/project-1');
31
+ assert.equal(projectPath({ orgId: 'org-uuid', id: 'p-uuid' }), '/o/org-uuid/projects/p-uuid');
32
+
33
+ // ── empty ────────────────────────────────────────────────────────────────────
34
+ assert.match(formatProjectIndex([]), /no projects/i);
35
+
36
+ // ── the reported failure: 180 projects must stay far under the 90KB cap ──────
37
+ const many = Array.from({ length: 180 }, (_, i) => mk(i + 1));
38
+ const out = formatProjectIndex(many, { now: NOW });
39
+ assert.ok(out.length < 10_000, `180 projects rendered ${out.length} chars, expected <10KB`);
40
+ assert.match(out, /^180 projects/, 'header reports the true total, not the shown count');
41
+ assert.match(out, /…and 130 more/, 'tail names what was withheld');
42
+ assert.equal(out.split('\n').filter((l) => l.startsWith(' /o/')).length, 50, 'caps at 50 lines');
43
+
44
+ // Recency order: newest-touched first, so the tail that gets cut is the stale end.
45
+ const shownPaths = out.split('\n').filter((l) => l.startsWith(' /o/')).map((l) => l.trim().split(/\s+/)[0]);
46
+ assert.equal(shownPaths[0], '/o/acme/projects/project-1', 'most recently touched leads');
47
+ assert.equal(shownPaths[49], '/o/acme/projects/project-50');
48
+ assert.ok(!out.includes('project-180'), 'stale tail is the part dropped');
49
+
50
+ // updatedAt wins over createdAt even when createdAt ordering disagrees.
51
+ const stale = mk(1, { slug: 'old-but-busy', createdAt: new Date(NOW - 900 * day).toISOString(), updatedAt: new Date(NOW - 1 * day).toISOString() });
52
+ const fresh = mk(2, { slug: 'new-but-idle', createdAt: new Date(NOW - 2 * day).toISOString(), updatedAt: new Date(NOW - 300 * day).toISOString() });
53
+ const ordered = formatProjectIndex([fresh, stale], { now: NOW });
54
+ assert.ok(ordered.indexOf('old-but-busy') < ordered.indexOf('new-but-idle'), 'sorts on updatedAt, not createdAt');
55
+
56
+ // Missing activity data must not crash or sort to the top.
57
+ const noStats = formatProjectIndex([{ id: 'x', slug: 'bare', orgSlug: 'acme' }, mk(1)], { now: NOW });
58
+ assert.match(noStats, /\/o\/acme\/projects\/bare/);
59
+
60
+ // ── header carries the session binding, not a shared cursor ──────────────────
61
+ const bound = formatProjectIndex([mk(1)], { boundPath: '/o/acme/projects/project-1', now: NOW });
62
+ assert.match(bound, /bound: \/o\/acme\/projects\/project-1/);
63
+ assert.ok(!formatProjectIndex([mk(1)], { now: NOW }).includes('bound:'), 'no binding, no bound line');
64
+
65
+ // Multi-org listings say so; single-org ones stay quiet.
66
+ const multi = formatProjectIndex([mk(1), mk(2, { orgSlug: 'globex' })], { now: NOW });
67
+ assert.match(multi, /2 orgs/);
68
+ assert.ok(!formatProjectIndex([mk(1), mk(2)], { now: NOW }).includes('orgs'));
69
+
70
+ // ── ages ─────────────────────────────────────────────────────────────────────
71
+ const ages = formatProjectIndex([
72
+ mk(1, { slug: 'a', updatedAt: new Date(NOW).toISOString() }),
73
+ mk(2, { slug: 'b', updatedAt: new Date(NOW - 3 * day).toISOString() }),
74
+ mk(3, { slug: 'c', updatedAt: new Date(NOW - 21 * day).toISOString() }),
75
+ mk(4, { slug: 'd', updatedAt: new Date(NOW - 90 * day).toISOString() }),
76
+ ], { now: NOW });
77
+ for (const [slug, age] of [['a', 'today'], ['b', '3d'], ['c', '3w'], ['d', '3mo']]) {
78
+ assert.match(ages, new RegExp(`projects/${slug}\\s+3 frames\\s+${age}\\b`), `${slug} → ${age}`);
79
+ }
80
+
81
+ // Frame count reads as English, and absent counts render no column at all.
82
+ assert.match(formatProjectIndex([mk(1, { frameCount: 1 })], { now: NOW }), /1 frame\s+1d/);
83
+ assert.match(formatProjectIndex([mk(1, { frameCount: 0 })], { now: NOW }), /0 frames\s+1d/);
84
+ const noCount = formatProjectIndex([mk(1, { frameCount: undefined })], { now: NOW });
85
+ assert.ok(!noCount.includes('frame'), 'no frameCount, no frames column');
86
+ assert.match(noCount, /1d$/m, 'age still renders without a count');
87
+
88
+ console.log('ok — project index shape, ordering, bounds');
@@ -0,0 +1,29 @@
1
+ // Regression for the /skills path split used by fs(ls|read|write|rm).
2
+ // Run: `node mcp/test-skill-paths.mjs`. No framework — asserts the pure core.
3
+ //
4
+ // Why this exists: a skill is a directory. Before this split the handler kept
5
+ // only the first segment, so `fs(write, path="/skills/<slug>/README.md")` wrote
6
+ // SKILL.md instead — the agent's README landed on top of the skill body and the
7
+ // create-time README gate stayed unsatisfiable. isSkillMd is the load-bearing
8
+ // bit: true routes to the skills row, false routes to skill_files.
9
+ import assert from 'node:assert/strict';
10
+ import { splitSkillPath } from './server.mjs';
11
+
12
+ // Root and bare slug — no file, so the skill row itself.
13
+ assert.deepEqual(splitSkillPath('/skills'), { slug: '', filePath: '', isSkillMd: true });
14
+ assert.deepEqual(splitSkillPath('/skills/'), { slug: '', filePath: '', isSkillMd: true });
15
+ assert.deepEqual(splitSkillPath('/skills/my-skill'), { slug: 'my-skill', filePath: '', isSkillMd: true });
16
+ assert.deepEqual(splitSkillPath('/skills/my-skill/'), { slug: 'my-skill', filePath: '', isSkillMd: true });
17
+
18
+ // SKILL.md is the skill body, addressed explicitly — same destination as the bare slug.
19
+ assert.deepEqual(splitSkillPath('/skills/my-skill/SKILL.md'), { slug: 'my-skill', filePath: 'SKILL.md', isSkillMd: true });
20
+
21
+ // Everything else is a supporting file, and the subpath survives.
22
+ assert.deepEqual(splitSkillPath('/skills/my-skill/README.md'), { slug: 'my-skill', filePath: 'README.md', isSkillMd: false });
23
+ assert.deepEqual(splitSkillPath('/skills/my-skill/references/api.md'), { slug: 'my-skill', filePath: 'references/api.md', isSkillMd: false });
24
+
25
+ // Case matters: readme.md is a different file from README.md, and neither is SKILL.md.
26
+ assert.equal(splitSkillPath('/skills/s/skill.md').isSkillMd, false);
27
+
28
+ console.log('skill path split ok — SKILL.md routes to the skill row, everything else to skill files');
29
+ process.exit(0);
@@ -0,0 +1,229 @@
1
+ <style>
2
+ :root {
3
+ --bg: #fafafa;
4
+ --card: #fff;
5
+ --border: #e5e7eb;
6
+ --muted: #6b7280;
7
+ --text: #1a1a2e;
8
+ --accent: #533afd;
9
+ --accent-light: #ede9fe;
10
+ }
11
+ * { box-sizing: border-box; margin: 0; padding: 0; }
12
+ html, body { height: 100%; }
13
+ body {
14
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ background: var(--bg);
16
+ color: var(--text);
17
+ padding: 16px 18px;
18
+ }
19
+ .header {
20
+ display: flex;
21
+ justify-content: space-between;
22
+ align-items: baseline;
23
+ margin-bottom: 14px;
24
+ }
25
+ .header .title { font-size: 14px; font-weight: 600; }
26
+ .header .meta { font-size: 12px; color: var(--muted); }
27
+ .header a {
28
+ color: var(--accent);
29
+ text-decoration: none;
30
+ font-weight: 500;
31
+ font-size: 12px;
32
+ margin-left: 12px;
33
+ }
34
+ .header a:hover { text-decoration: underline; }
35
+
36
+ .layer {
37
+ background: var(--card);
38
+ border: 1px solid var(--border);
39
+ border-radius: 10px;
40
+ padding: 12px 14px;
41
+ margin-bottom: 10px;
42
+ }
43
+ .layer h3 {
44
+ font-size: 12px;
45
+ font-weight: 600;
46
+ color: var(--muted);
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.04em;
49
+ margin-bottom: 8px;
50
+ display: flex;
51
+ justify-content: space-between;
52
+ }
53
+ .layer h3 .count {
54
+ background: var(--accent-light);
55
+ color: var(--accent);
56
+ padding: 1px 8px;
57
+ border-radius: 999px;
58
+ font-size: 10px;
59
+ }
60
+ .frames {
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ gap: 6px;
64
+ }
65
+ .frame {
66
+ background: var(--bg);
67
+ border: 1px solid var(--border);
68
+ border-radius: 6px;
69
+ padding: 6px 10px;
70
+ font-size: 12px;
71
+ color: var(--text);
72
+ text-decoration: none;
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: 6px;
76
+ transition: border-color 0.15s, color 0.15s;
77
+ }
78
+ .frame:hover {
79
+ border-color: var(--accent);
80
+ color: var(--accent);
81
+ }
82
+ .frame .lane {
83
+ color: var(--muted);
84
+ font-size: 11px;
85
+ }
86
+ .empty {
87
+ text-align: center;
88
+ color: var(--muted);
89
+ font-size: 13px;
90
+ padding: 32px;
91
+ }
92
+ .project {
93
+ background: var(--card);
94
+ border: 1px solid var(--border);
95
+ border-radius: 10px;
96
+ padding: 12px 14px;
97
+ margin-bottom: 8px;
98
+ display: flex;
99
+ justify-content: space-between;
100
+ align-items: center;
101
+ text-decoration: none;
102
+ color: inherit;
103
+ transition: border-color 0.15s;
104
+ }
105
+ .project:hover { border-color: var(--accent); }
106
+ .project .name { font-weight: 500; font-size: 14px; }
107
+ .project .desc { font-size: 12px; color: var(--muted); margin-top: 2px; }
108
+ .project .badge {
109
+ font-size: 11px;
110
+ background: var(--accent-light);
111
+ color: var(--accent);
112
+ padding: 2px 8px;
113
+ border-radius: 999px;
114
+ }
115
+ </style>
116
+
117
+ <div class="header">
118
+ <div>
119
+ <div class="title" id="title">Loading…</div>
120
+ <div class="meta" id="subtitle"></div>
121
+ </div>
122
+ <a id="open" href="#" target="_blank" rel="noopener" hidden>Open in canvas →</a>
123
+ </div>
124
+ <div id="root"></div>
125
+
126
+ <script>
127
+ function escapeHtml(s) {
128
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
129
+ }
130
+
131
+ // Derive the Drafted server origin from payload URLs (frameUrl, canvasUrl) so the
132
+ // widget deep-links correctly on local installs and hosted servers alike — never
133
+ // hardcode https://drafted.live (breaks localhost/self-hosted deployments).
134
+ function serverOrigin(sc) {
135
+ const candidates = [
136
+ sc.canvasUrl, sc.serverUrl,
137
+ ...(Array.isArray(sc.projects) ? sc.projects.map(p => p.canvasUrl).filter(Boolean) : []),
138
+ ...(Array.isArray(sc.projects) ? sc.projects.map(p => p.frameUrl).filter(Boolean) : []),
139
+ ...(Array.isArray(sc.entries) ? sc.entries.map(e => e.frameUrl).filter(Boolean) : []),
140
+ ];
141
+ for (const u of candidates) {
142
+ try { return new URL(u).origin; } catch { /* keep looking */ }
143
+ }
144
+ return 'https://drafted.live';
145
+ }
146
+
147
+ function renderProjects(projects, activeId, origin) {
148
+ if (!projects?.length) return '<div class="empty">No projects yet. Use project(action="create") to start one.</div>';
149
+ return projects.slice(0, 30).map(p => {
150
+ const isActive = p.id === activeId;
151
+ const url = p.slug ? `${origin}/project/${escapeHtml(p.slug)}` : '#';
152
+ return `
153
+ <a class="project" href="${url}" target="_blank" rel="noopener">
154
+ <div>
155
+ <div class="name">${escapeHtml(p.name || p.slug || 'Untitled')}</div>
156
+ ${p.description ? `<div class="desc">${escapeHtml(p.description)}</div>` : ''}
157
+ </div>
158
+ ${isActive ? '<span class="badge">active</span>' : ''}
159
+ </a>
160
+ `;
161
+ }).join('');
162
+ }
163
+
164
+ function renderLayers(byLayer) {
165
+ if (!byLayer || !Object.keys(byLayer).length) return '<div class="empty">No frames in this view.</div>';
166
+ return Object.entries(byLayer).map(([layer, frames]) => `
167
+ <div class="layer">
168
+ <h3>${escapeHtml(layer)} <span class="count">${frames.length}</span></h3>
169
+ <div class="frames">
170
+ ${frames.slice(0, 24).map(f => `
171
+ <a class="frame" href="${escapeHtml(f.frameUrl || '#')}" target="_blank" rel="noopener">
172
+ <span>${escapeHtml(f.label || f.filename || f.path || '?')}</span>
173
+ ${f.lane && f.lane !== 'default' ? `<span class="lane">${escapeHtml(f.lane)}</span>` : ''}
174
+ </a>
175
+ `).join('')}
176
+ </div>
177
+ </div>
178
+ `).join('');
179
+ }
180
+
181
+ function render(payload) {
182
+ const sc = payload?.structuredContent || {};
183
+ const root = document.getElementById('root');
184
+
185
+ // project(action="list") shape
186
+ if (Array.isArray(sc.projects)) {
187
+ document.getElementById('title').textContent = `${sc.projects.length} project${sc.projects.length === 1 ? '' : 's'}`;
188
+ document.getElementById('subtitle').textContent = sc.activeProject ? 'One active' : 'None active — use project(action="open") to switch';
189
+ root.innerHTML = renderProjects(sc.projects, sc.activeProject, serverOrigin(sc));
190
+ return;
191
+ }
192
+
193
+ // ls shape
194
+ if (sc.byLayer || sc.entries) {
195
+ const byLayer = sc.byLayer || groupByLayer(sc.entries || []);
196
+ const total = Object.values(byLayer).reduce((sum, arr) => sum + arr.length, 0);
197
+ document.getElementById('title').textContent = sc.project || 'Project canvas';
198
+ document.getElementById('subtitle').textContent = `${total} frame${total === 1 ? '' : 's'} · ${Object.keys(byLayer).length} layer${Object.keys(byLayer).length === 1 ? '' : 's'}`;
199
+ if (sc.canvasUrl) {
200
+ const link = document.getElementById('open');
201
+ link.href = sc.canvasUrl;
202
+ link.hidden = false;
203
+ }
204
+ root.innerHTML = renderLayers(byLayer);
205
+ return;
206
+ }
207
+
208
+ root.innerHTML = '<div class="empty">No data to display.</div>';
209
+ }
210
+
211
+ function groupByLayer(entries) {
212
+ const out = {};
213
+ for (const e of entries) {
214
+ const layer = e.layer || 'unsorted';
215
+ (out[layer] ??= []).push(e);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ window.addEventListener('message', (event) => {
221
+ const msg = event.data;
222
+ if (!msg || typeof msg !== 'object') return;
223
+ if (msg.method === 'ui/notifications/tool-result' && msg.params?.result) {
224
+ render(msg.params.result);
225
+ }
226
+ });
227
+
228
+ if (window.openai?.toolResult) render(window.openai.toolResult);
229
+ </script>