drafted 1.17.17 → 1.18.0
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 +16 -28
- package/cli/drafted.mjs +34 -6
- package/mcp/server.mjs +80 -3
- package/package.json +16 -80
- package/skills/import-website-to-drafted.md +317 -0
- package/agent-instructions/global.md +0 -3
- package/cli/repo-scan.mjs +0 -146
- package/install-mcp.sh +0 -1269
- package/mcp/active-project-store.mjs +0 -81
- package/mcp/gates.mjs +0 -124
- package/mcp/test-file-path-text.mjs +0 -58
- package/mcp/test-org-guards.mjs +0 -172
- 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 -17
- package/plugin/skills/drafted/SKILL.md +0 -88
- package/server/lib/umami.mjs +0 -162
- package/src/shared/excalidraw.mjs +0 -84
- 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/test-excalidraw-merge.mjs +0 -53
- package/src/shared/wiki-excalidraw.mjs +0 -90
- /package/{src/shared → shared}/constants.mjs +0 -0
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Active-project persistence for the stdio MCP.
|
|
3
|
-
*
|
|
4
|
-
* The active project lives only in the in-memory sessionStates Map, so a
|
|
5
|
-
* `system restart mode=mcp` / skill-edit pass that re-spawns the stdio MCP
|
|
6
|
-
* wipes it — the recurring "active project keeps resetting, re-open it" churn.
|
|
7
|
-
* Persisting it lets the long-lived stdio process rehydrate transparently after
|
|
8
|
-
* a restart.
|
|
9
|
-
*
|
|
10
|
-
* Keyed by SERVER URL + cwd so two concurrent same-machine agent sessions (each
|
|
11
|
-
* its own stdio MCP, same launch config) don't clobber each other's active
|
|
12
|
-
* project. The server is part of the key because project and org ids are only
|
|
13
|
-
* meaningful within one server's DATABASE: this repo registers both a prod
|
|
14
|
-
* (`https://drafted.live`) and a local-dev (`http://localhost:3477`) stdio MCP,
|
|
15
|
-
* and with a cwd-only key the local MCP's project+boundOrgId were rehydrated by
|
|
16
|
-
* the prod MCP at boot — which pinned the prod session's working org to an org
|
|
17
|
-
* id that exists only in the local dev DB (`get_org` reported a workingOrg with
|
|
18
|
-
* name:null that was in no membership, and every project-less call failed with
|
|
19
|
-
* `not a member of org "<local-uuid>"`). Cross-database state must never share a
|
|
20
|
-
* key. Only the stdio process persists here; HTTP/remote user sessions are keyed
|
|
21
|
-
* by a real Drafted session id and never call into this store.
|
|
22
|
-
*
|
|
23
|
-
* Side-effect-free on import (no network, no eager writes) so it can be unit
|
|
24
|
-
* tested against the real code without booting the MCP server.
|
|
25
|
-
*/
|
|
26
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
27
|
-
import { join, dirname } from 'path';
|
|
28
|
-
import { homedir } from 'os';
|
|
29
|
-
|
|
30
|
-
const DEFAULT_FILE = process.env.DRAFTED_MCP_STATE_FILE || join(homedir(), '.drafted', 'mcp-state.json');
|
|
31
|
-
|
|
32
|
-
function defaultCwd() {
|
|
33
|
-
try { return process.cwd(); } catch { return '__default__'; }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// serverUrl first: a URL never contains "|", so the composite key is unambiguous.
|
|
37
|
-
function stateKey(cwd, serverUrl) {
|
|
38
|
-
return serverUrl ? `${serverUrl}|${cwd}` : cwd;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
42
|
-
try {
|
|
43
|
-
const all = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
-
// Pre-composite-key entries (bare cwd) are NOT read back: they carry no record of
|
|
45
|
-
// which server minted their ids, and adopting one is exactly the cross-DB bleed
|
|
46
|
-
// above. Worst case the agent re-opens its project once.
|
|
47
|
-
const e = all && all[stateKey(cwd, serverUrl)];
|
|
48
|
-
if (e && e.activeProjectId) {
|
|
49
|
-
return {
|
|
50
|
-
activeProjectId: e.activeProjectId,
|
|
51
|
-
activeProjectMeta: e.activeProjectMeta || null,
|
|
52
|
-
boundOrgId: e.boundOrgId || null,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
} catch { /* no state file yet / unreadable — start fresh */ }
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
60
|
-
try {
|
|
61
|
-
let all = {};
|
|
62
|
-
try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
|
|
63
|
-
const key = stateKey(cwd, serverUrl);
|
|
64
|
-
if (key !== cwd) delete all[cwd]; // drop the un-namespaced legacy entry for this cwd
|
|
65
|
-
if (entry && entry.activeProjectId) {
|
|
66
|
-
all[key] = {
|
|
67
|
-
activeProjectId: entry.activeProjectId,
|
|
68
|
-
activeProjectMeta: entry.activeProjectMeta || null,
|
|
69
|
-
boundOrgId: entry.boundOrgId || null,
|
|
70
|
-
};
|
|
71
|
-
} else {
|
|
72
|
-
delete all[key];
|
|
73
|
-
}
|
|
74
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
75
|
-
writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
|
|
76
|
-
return true;
|
|
77
|
-
} catch {
|
|
78
|
-
// Best-effort; persistence is an optimization, never block a tool on it.
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|
package/mcp/gates.mjs
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// Pure gate logic for the Drafted compounding harness.
|
|
2
|
-
//
|
|
3
|
-
// These helpers are intentionally side-effect free (no `api()`, no network, no
|
|
4
|
-
// module state) so they can be unit tested in isolation. mcp/server.mjs imports
|
|
5
|
-
// them and wires the decisions into the tool handlers + per-session getState().
|
|
6
|
-
//
|
|
7
|
-
// See docs/plans/compounding-harness.md and the Drafted "gates-checklist" frame.
|
|
8
|
-
|
|
9
|
-
// One combined per-project budget for the auto-injected priming set:
|
|
10
|
-
// attached-skill bodies + project anchor bodies + the active layer's rules.
|
|
11
|
-
// Single source of truth lives in src/shared so the server-side deposit caps
|
|
12
|
-
// enforce the identical limit. Re-exported here for the MCP gate helpers.
|
|
13
|
-
import { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget } from '../src/shared/gate-budget.mjs';
|
|
14
|
-
export { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget };
|
|
15
|
-
|
|
16
|
-
// ── Per-session gate flags (reset every MCP session) ──────────────────────────
|
|
17
|
-
|
|
18
|
-
export function createGateState() {
|
|
19
|
-
return { wikiSearched: false, skillSearched: false, templateSearched: false };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// kind: 'wiki' | 'skill' | 'template'
|
|
23
|
-
export function markSearched(gateState, kind) {
|
|
24
|
-
if (kind === 'wiki') gateState.wikiSearched = true;
|
|
25
|
-
else if (kind === 'skill') gateState.skillSearched = true;
|
|
26
|
-
else if (kind === 'template') gateState.templateSearched = true;
|
|
27
|
-
return gateState;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ── Enforce gates (create chain). Return an error string if blocked, else null ─
|
|
31
|
-
|
|
32
|
-
export function g1Block(gateState, wikiIndex) {
|
|
33
|
-
if (gateState.wikiSearched) return null;
|
|
34
|
-
let msg =
|
|
35
|
-
'G1: search the org wiki before reading or editing anything. ' +
|
|
36
|
-
'Call fs(search, path="/wiki", query="<relevant terms>") first, then retry. ' +
|
|
37
|
-
'More knowledge = less searching — start by drawing on what the org already knows.';
|
|
38
|
-
if (wikiIndex) msg += `\n\nWiki index (what exists to search):\n${wikiIndex}`;
|
|
39
|
-
return msg;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function g2Block(gateState) {
|
|
43
|
-
if (gateState.skillSearched) return null;
|
|
44
|
-
return (
|
|
45
|
-
'G2: search for prior-art skills before creating one. ' +
|
|
46
|
-
'Call fs(search, path="/skills", query="<topic>") first — if a close match exists, improve it ' +
|
|
47
|
-
'(/drafted:improve-skill) instead of duplicating — then retry skill(action="add").'
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function g3Block(gateState) {
|
|
52
|
-
const missing = [];
|
|
53
|
-
if (!gateState.wikiSearched) missing.push('fs(search, path="/wiki")');
|
|
54
|
-
if (!gateState.skillSearched) missing.push('fs(search, path="/skills")');
|
|
55
|
-
if (!gateState.templateSearched) missing.push('fs(ls, path="/skills")');
|
|
56
|
-
if (missing.length === 0) return null;
|
|
57
|
-
return (
|
|
58
|
-
`G3: search before creating a project. Run ${missing.join(', ')} first ` +
|
|
59
|
-
'(reuse existing knowledge, skills, and templates), then retry project(action="create").'
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── Per-project context budget (auto-inject set) ──────────────────────────────
|
|
64
|
-
|
|
65
|
-
function itemChars(it) {
|
|
66
|
-
if (typeof it === 'number') return it;
|
|
67
|
-
if (typeof it === 'string') return it.length;
|
|
68
|
-
if (it && typeof it === 'object') {
|
|
69
|
-
if (typeof it.chars === 'number') return it.chars;
|
|
70
|
-
if (it.content != null) return String(it.content).length;
|
|
71
|
-
}
|
|
72
|
-
return 0;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function sumChars(items) {
|
|
76
|
-
if (!Array.isArray(items)) return 0;
|
|
77
|
-
return items.reduce((total, it) => total + itemChars(it), 0);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function budgetRemaining(currentTotal, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
81
|
-
return Math.max(0, budget - currentTotal);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function wouldExceedBudget(currentTotal, addChars, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
85
|
-
return currentTotal + addChars > budget;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function budgetError(currentTotal, addChars, label, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
89
|
-
const remaining = budgetRemaining(currentTotal, budget);
|
|
90
|
-
return (
|
|
91
|
-
`Per-project context budget exceeded: ${label} needs ${addChars} chars but only ${remaining} ` +
|
|
92
|
-
`of ${budget} remain. Tighten existing attached skills / anchors / layer rules first ` +
|
|
93
|
-
`(see /drafted:improve-project-harness).`
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── G6 layer-rule default detection ───────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
// A layer prompt is "default" (skip G6 inject) when it is empty or byte-identical
|
|
100
|
-
// to the template's default prompt for that layer key.
|
|
101
|
-
export function isLayerPromptDefault(layerPrompt, templateDefaultPrompt) {
|
|
102
|
-
const p = (layerPrompt ?? '').trim();
|
|
103
|
-
if (p === '') return true;
|
|
104
|
-
const d = (templateDefaultPrompt ?? '').trim();
|
|
105
|
-
return d !== '' && p === d;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
|
|
109
|
-
return !isLayerPromptDefault(layerPrompt, templateDefaultPrompt);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── Wiki index formatting (bounded map injected with the G1 block) ────────────
|
|
113
|
-
|
|
114
|
-
export function formatWikiIndex(pages, max = 50) {
|
|
115
|
-
if (!Array.isArray(pages) || pages.length === 0) return '(wiki is empty — no pages yet)';
|
|
116
|
-
const lines = pages.slice(0, max).map((p) => {
|
|
117
|
-
const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
|
|
118
|
-
const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
|
|
119
|
-
return ` ${path}${title}`;
|
|
120
|
-
});
|
|
121
|
-
const more = pages.length > max ? `\n …and ${pages.length - max} more` : '';
|
|
122
|
-
return lines.join('\n') + more;
|
|
123
|
-
}
|
|
124
|
-
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression for textFromLocalFile() — the `file_path` text seam used by
|
|
3
|
-
* fs(write) on /wiki and /skills.
|
|
4
|
-
*
|
|
5
|
-
* node mcp/test-file-path-text.mjs
|
|
6
|
-
*
|
|
7
|
-
* Why this exists: /projects uploads bytes (base64 + contentType), /wiki and
|
|
8
|
-
* /skills take markdown text. Routing a file down the wrong seam stores
|
|
9
|
-
* mojibake that only surfaces when someone reads the page back, so the binary
|
|
10
|
-
* guard below is the load-bearing assertion, not a nicety.
|
|
11
|
-
*/
|
|
12
|
-
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
-
import { tmpdir } from 'node:os';
|
|
15
|
-
import { join } from 'node:path';
|
|
16
|
-
import { textFromLocalFile } from './server.mjs';
|
|
17
|
-
|
|
18
|
-
const dir = mkdtempSync(join(tmpdir(), 'drafted-filepath-'));
|
|
19
|
-
|
|
20
|
-
// Plain markdown round-trips byte-for-byte.
|
|
21
|
-
const md = join(dir, 'page.md');
|
|
22
|
-
const body = '# Title\n\nBody with a UTF-8 em dash — and an accent é.\n';
|
|
23
|
-
writeFileSync(md, body, 'utf8');
|
|
24
|
-
assert.equal(textFromLocalFile(md), body);
|
|
25
|
-
|
|
26
|
-
// Multi-byte characters survive — a latin1 read would mangle these.
|
|
27
|
-
assert.ok(textFromLocalFile(md).includes('—'));
|
|
28
|
-
assert.ok(textFromLocalFile(md).includes('é'));
|
|
29
|
-
|
|
30
|
-
// Empty file returns '' rather than throwing. The caller decides: both write
|
|
31
|
-
// sites treat falsy text as "no content supplied" and error with guidance.
|
|
32
|
-
const empty = join(dir, 'empty.md');
|
|
33
|
-
writeFileSync(empty, '');
|
|
34
|
-
assert.equal(textFromLocalFile(empty), '');
|
|
35
|
-
|
|
36
|
-
// Binary is refused. A PNG header contains NUL bytes.
|
|
37
|
-
const png = join(dir, 'shot.png');
|
|
38
|
-
writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]));
|
|
39
|
-
assert.throws(() => textFromLocalFile(png), /looks binary/);
|
|
40
|
-
|
|
41
|
-
// Directories are refused with a clear message, not an EISDIR stack trace.
|
|
42
|
-
const sub = join(dir, 'nested');
|
|
43
|
-
mkdirSync(sub);
|
|
44
|
-
assert.throws(() => textFromLocalFile(sub), /is a directory/);
|
|
45
|
-
|
|
46
|
-
// Missing files name the path the caller passed, not the resolved one.
|
|
47
|
-
assert.throws(() => textFromLocalFile(join(dir, 'nope.md')), /file not found/);
|
|
48
|
-
|
|
49
|
-
// Relative paths resolve against cwd rather than being read blindly.
|
|
50
|
-
assert.throws(() => textFromLocalFile('definitely-not-here-xyz.md'), /file not found/);
|
|
51
|
-
|
|
52
|
-
console.log('file_path text seam ok — utf8 preserved, binary and dirs refused');
|
|
53
|
-
|
|
54
|
-
// Importing server.mjs opens the MCP WebSocket at module scope, which pins the
|
|
55
|
-
// event loop open forever. mcp/test-org-guards.mjs only exits because it
|
|
56
|
-
// finishes in ~176ms — before the socket connects — so it passes on a race
|
|
57
|
-
// rather than by design. Exit explicitly instead of inheriting that luck.
|
|
58
|
-
process.exit(0);
|
package/mcp/test-org-guards.mjs
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
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);
|
|
@@ -1,229 +0,0 @@
|
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|