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.
- package/README.md +15 -34
- package/cli/drafted.mjs +1 -1
- package/mcp/chunked-write.mjs +178 -0
- package/mcp/server.mjs +189 -12
- package/package.json +21 -27
- package/skills/import-website-to-drafted.md +329 -0
- package/agent-instructions/global.md +0 -3
- package/cli/google-setup.sh +0 -186
- package/cli/mail-watch.mjs +0 -237
- package/cli/open-url.mjs +0 -17
- package/cli/repo-scan.mjs +0 -240
- package/cli/skill-setup-trust.mjs +0 -17
- package/install-mcp.sh +0 -1440
- package/mcp/active-project-store.mjs +0 -81
- package/mcp/gates.mjs +0 -206
- package/mcp/gws-status.mjs +0 -31
- package/mcp/repo-address.mjs +0 -215
- package/mcp/test-file-path-text.mjs +0 -58
- package/mcp/test-org-guards.mjs +0 -234
- package/mcp/test-project-index.mjs +0 -88
- package/mcp/test-search-terms.mjs +0 -79
- package/mcp/test-skill-paths.mjs +0 -29
- package/mcp/widgets/canvas-overview.html +0 -229
- package/mcp/widgets/frame-preview.html +0 -162
- package/plugin/commands/create-project.md +0 -20
- package/plugin/commands/create-skill.md +0 -18
- package/plugin/commands/extract.md +0 -16
- package/plugin/commands/improve-project-harness.md +0 -16
- package/plugin/commands/improve-skill.md +0 -14
- package/plugin/commands/improve-wiki.md +0 -14
- package/plugin/commands/ingest.md +0 -20
- package/plugin/commands/onboard-drafted.md +0 -18
- package/plugin/skills/drafted/SKILL.md +0 -102
- package/server/lib/umami.mjs +0 -164
- package/src/shared/anchor-scope.mjs +0 -98
- package/src/shared/excalidraw.mjs +0 -94
- package/src/shared/folder-path.mjs +0 -176
- package/src/shared/gate-budget.mjs +0 -30
- package/src/shared/minion-presets.mjs +0 -67
- package/src/shared/okf-log.mjs +0 -62
- package/src/shared/record-conformance.mjs +0 -167
- package/src/shared/root-route.mjs +0 -45
- package/src/shared/task-id.mjs +0 -54
- package/src/shared/test-excalidraw-merge.mjs +0 -52
- package/src/shared/wiki-excalidraw.mjs +0 -90
- /package/{src/shared → shared}/constants.mjs +0 -0
|
@@ -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,206 +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
|
-
// `empty` lets a caller say what an empty result MEANS: a zero-hit search or an
|
|
115
|
-
// empty folder is not "the wiki is empty" (that read as a contradiction when other
|
|
116
|
-
// searches of the same org had returned pages).
|
|
117
|
-
export function formatWikiIndex(pages, max = 50, empty = '(wiki is empty — no pages yet)') {
|
|
118
|
-
if (!Array.isArray(pages) || pages.length === 0) return empty;
|
|
119
|
-
const lines = pages.slice(0, max).map((p) => {
|
|
120
|
-
const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
|
|
121
|
-
const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
|
|
122
|
-
return ` ${path}${title}`;
|
|
123
|
-
});
|
|
124
|
-
const more = pages.length > max ? `\n …and ${pages.length - max} more` : '';
|
|
125
|
-
return lines.join('\n') + more;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// A frame read is capped per page, below MAX_TOOL_RESULT_CHARS, because the
|
|
129
|
-
// content is JSON-escaped on its way out (quotes, newlines) and the blunt cap
|
|
130
|
-
// above cuts mid-token with no way back: `lines` cannot split a minified frame
|
|
131
|
-
// that is ONE line, so ~90 KB of it was simply lost. Paging by character gives
|
|
132
|
-
// every byte an address.
|
|
133
|
-
export const READ_PAGE_CHARS = 60_000;
|
|
134
|
-
|
|
135
|
-
// "start-end" (0-based, end exclusive) or "start" -> the window to return.
|
|
136
|
-
export function charWindow(spec, total) {
|
|
137
|
-
const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(String(spec ?? ''));
|
|
138
|
-
if (spec != null && spec !== '' && !m) throw new Error(`chars must look like "0-60000" (got "${spec}")`);
|
|
139
|
-
const start = m ? Math.min(Number(m[1]), total) : 0;
|
|
140
|
-
const end = Math.min(m?.[2] ? Number(m[2]) : start + READ_PAGE_CHARS, start + READ_PAGE_CHARS, total);
|
|
141
|
-
return { start, end: Math.max(start, end) };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── Project index formatting ──────────────────────────────────────────────────
|
|
145
|
-
// `ls /projects` is an ls, not a stat: one addressable line per project. The
|
|
146
|
-
// previous shape returned the raw /api/projects rows — ~2.4KB each once the
|
|
147
|
-
// per-project `layers` config (912B avg on prod) is pretty-printed — so an org
|
|
148
|
-
// with 180 projects blew the 90KB tool-result cap and came back truncated
|
|
149
|
-
// mid-JSON at 38. Names and paths are what an agent needs to pick one; layers,
|
|
150
|
-
// format, template and share rollups belong on `ls` of the single project.
|
|
151
|
-
|
|
152
|
-
function relAge(then, now = Date.now()) {
|
|
153
|
-
const t = then ? new Date(then).getTime() : NaN;
|
|
154
|
-
if (!Number.isFinite(t)) return '';
|
|
155
|
-
const d = Math.max(0, Math.floor((now - t) / 86400000));
|
|
156
|
-
if (d === 0) return 'today';
|
|
157
|
-
if (d < 7) return `${d}d`;
|
|
158
|
-
if (d < 60) return `${Math.floor(d / 7)}w`;
|
|
159
|
-
if (d < 730) return `${Math.floor(d / 30)}mo`;
|
|
160
|
-
return `${Math.floor(d / 365)}y`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function projectPath(p) {
|
|
164
|
-
const org = p.orgSlug || p.orgId || '';
|
|
165
|
-
const name = p.slug || p.name || p.id;
|
|
166
|
-
const folder = p.folder ? `${p.folder}/` : '';
|
|
167
|
-
return `/o/${org}/projects/${folder}${name}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* One line per project, most-recently-touched first, with a header naming where
|
|
172
|
-
* this session is bound. `boundPath` comes from the MCP session's own state —
|
|
173
|
-
* never the shared active-project row (DRAFT-36: nothing another session can
|
|
174
|
-
* rewrite may drive what this one reports).
|
|
175
|
-
*/
|
|
176
|
-
export function formatProjectIndex(projects, { max = 50, boundPath = null, now = Date.now() } = {}) {
|
|
177
|
-
if (!Array.isArray(projects) || projects.length === 0) {
|
|
178
|
-
return '(no projects — create one with fs(mkdir, path="/o/<org>/projects/<name>"))';
|
|
179
|
-
}
|
|
180
|
-
const sorted = [...projects].sort((a, b) => {
|
|
181
|
-
const at = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
|
182
|
-
const bt = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
|
183
|
-
return bt - at;
|
|
184
|
-
});
|
|
185
|
-
const shown = sorted.slice(0, max);
|
|
186
|
-
const paths = shown.map(projectPath);
|
|
187
|
-
const width = Math.min(60, Math.max(...paths.map((s) => s.length)));
|
|
188
|
-
const lines = shown.map((p, i) => {
|
|
189
|
-
const n = Number(p.frameCount);
|
|
190
|
-
const frames = Number.isFinite(n) && p.frameCount != null ? `${n} frame${n === 1 ? '' : 's'}` : '';
|
|
191
|
-
const age = relAge(p.updatedAt || p.createdAt, now);
|
|
192
|
-
const detail = [frames, age].filter(Boolean).join(' ');
|
|
193
|
-
return ` ${paths[i].padEnd(width)} ${detail}`.trimEnd();
|
|
194
|
-
});
|
|
195
|
-
const orgs = new Set(projects.map((p) => p.orgSlug || p.orgId).filter(Boolean));
|
|
196
|
-
const header = [
|
|
197
|
-
`${projects.length} project${projects.length === 1 ? '' : 's'}`,
|
|
198
|
-
orgs.size > 1 ? `${orgs.size} orgs` : null,
|
|
199
|
-
boundPath ? `bound: ${boundPath}` : null,
|
|
200
|
-
].filter(Boolean).join(' · ');
|
|
201
|
-
const more = projects.length > max
|
|
202
|
-
? `\n …and ${projects.length - max} more (recency order — narrow with pattern="<glob>" or fs(search, path="/projects", query="<terms>"))`
|
|
203
|
-
: '';
|
|
204
|
-
return `${header}\n\n${lines.join('\n')}${more}`;
|
|
205
|
-
}
|
|
206
|
-
|
package/mcp/gws-status.mjs
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// Local-capability probe for the Get set up page (see mcp/server.mjs, agent-capabilities).
|
|
2
|
-
// Its own module so tests can import it without opening the MCP WebSocket.
|
|
3
|
-
import { execFile } from 'child_process';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
import { join } from 'path';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Is Google's Workspace CLI signed in for this user, with Gmail readable?
|
|
9
|
-
* 'ready' | 'signed_out' | 'absent'. Checks the Drafted npm prefix first (where
|
|
10
|
-
* `drafted google-setup` installs it, and which a GUI-launched agent's PATH may
|
|
11
|
-
* lack), then PATH. Anything that is not Google's gws (Homebrew's `gws` is an
|
|
12
|
-
* unrelated tool) prints non-JSON and reads as 'absent'.
|
|
13
|
-
*/
|
|
14
|
-
export function gwsStatusFromJson(stdout) {
|
|
15
|
-
try {
|
|
16
|
-
const j = JSON.parse(stdout);
|
|
17
|
-
return j.has_refresh_token && (j.scopes || []).some((x) => String(x).includes('/auth/gmail')) ? 'ready' : 'signed_out';
|
|
18
|
-
} catch {
|
|
19
|
-
return 'absent';
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export function detectGwsStatus({ candidates = [join(homedir(), '.drafted', 'npm-global', 'bin', 'gws'), 'gws'] } = {}) {
|
|
23
|
-
const tryOne = (i) => new Promise((resolve) => {
|
|
24
|
-
if (i >= candidates.length) return resolve('absent');
|
|
25
|
-
execFile(candidates[i], ['auth', 'status'], { timeout: 5000, windowsHide: true }, (err, stdout) => {
|
|
26
|
-
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) return resolve(tryOne(i + 1));
|
|
27
|
-
resolve(gwsStatusFromJson(String(stdout || '')));
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
return tryOne(0).catch(() => 'absent');
|
|
31
|
-
}
|
package/mcp/repo-address.mjs
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What Drafted address does this working directory name?
|
|
3
|
-
*
|
|
4
|
-
* The MCP runs as a stdio process spawned in the user's repo. Until now it had
|
|
5
|
-
* no idea whether that repo was linked to a Drafted folder, so an agent working
|
|
6
|
-
* in a linked checkout would write to whatever org the session happened to
|
|
7
|
-
* inherit. The comment above `workingOrgId()` in server.mjs records where that
|
|
8
|
-
* ends: "trusting it is what wrote a Beoflow-bound agent's wiki page into
|
|
9
|
-
* Personal."
|
|
10
|
-
*
|
|
11
|
-
* Two rules shape this module:
|
|
12
|
-
*
|
|
13
|
-
* 1. It answers a PATH (/o/<org>/<folder>), never an orgId to stash. A stored
|
|
14
|
-
* org becomes an inherited default, which is the bug we are fixing, not a
|
|
15
|
-
* fix for it.
|
|
16
|
-
* 2. It does NOT normalize git URLs. Remotes go to the server raw and
|
|
17
|
-
* parseGitHubUrl (server/lib/repo-ingest.mjs) stays the single normalizer.
|
|
18
|
-
* A second normalizer that drifts from the first would reintroduce the
|
|
19
|
-
* split-master problem inside the fix itself.
|
|
20
|
-
*
|
|
21
|
-
* Side-effect free and network-free: probe() shells out to git, resolve() is
|
|
22
|
-
* pure. That split is what makes this testable without a repo or a server.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { execFileSync } from 'node:child_process';
|
|
26
|
-
import { readFileSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
28
|
-
|
|
29
|
-
/** Run a git command in `cwd`, or null if git fails for any reason. Never
|
|
30
|
-
* throws: not-a-repo, no git binary, and a detached HEAD are all ordinary. */
|
|
31
|
-
function git(args, cwd) {
|
|
32
|
-
try {
|
|
33
|
-
const out = execFileSync('git', args, {
|
|
34
|
-
cwd,
|
|
35
|
-
encoding: 'utf8',
|
|
36
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
-
timeout: 2000,
|
|
38
|
-
});
|
|
39
|
-
return out.trim() || null;
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Read the git identity of a working directory.
|
|
47
|
-
*
|
|
48
|
-
* `--show-toplevel` is what makes a monorepo subdirectory and a worktree behave:
|
|
49
|
-
* any cwd inside the tree normalizes to the repo root, and a worktree reports
|
|
50
|
-
* its own root while sharing the remote.
|
|
51
|
-
*
|
|
52
|
-
* Every remote is returned, origin first. A fork whose `origin` Drafted has
|
|
53
|
-
* never seen may still have an `upstream` that is linked, and resolving that is
|
|
54
|
-
* strictly better than reporting nothing.
|
|
55
|
-
*/
|
|
56
|
-
export function probeRepo(cwd = process.cwd()) {
|
|
57
|
-
const root = git(['rev-parse', '--show-toplevel'], cwd);
|
|
58
|
-
if (!root) return null;
|
|
59
|
-
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
60
|
-
const remotes = parseRemotes(git(['remote', '-v'], cwd));
|
|
61
|
-
return {
|
|
62
|
-
root,
|
|
63
|
-
// A detached HEAD reports the literal "HEAD"; that is not a branch name and
|
|
64
|
-
// must not be matched against repos.branch.
|
|
65
|
-
branch: branch && branch !== 'HEAD' ? branch : null,
|
|
66
|
-
remotes,
|
|
67
|
-
declared: readDeclaration(root),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** `git remote -v` → [{ name, url }], deduped, origin first then upstream. */
|
|
72
|
-
export function parseRemotes(text) {
|
|
73
|
-
const seen = new Map();
|
|
74
|
-
for (const line of String(text || '').split('\n')) {
|
|
75
|
-
const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
|
76
|
-
if (m && !seen.has(m[1])) seen.set(m[1], m[2]);
|
|
77
|
-
}
|
|
78
|
-
const rank = (n) => (n === 'origin' ? 0 : n === 'upstream' ? 1 : 2);
|
|
79
|
-
return [...seen.entries()]
|
|
80
|
-
.map(([name, url]) => ({ name, url }))
|
|
81
|
-
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The optional committed declaration, `<repo root>/.drafted/config.json`.
|
|
86
|
-
*
|
|
87
|
-
* This file SELECTS among candidates the server returned; it never grants
|
|
88
|
-
* access to anything. That distinction is the whole reason it is safe to
|
|
89
|
-
* commit: "which org is this repo?" is a per-USER question (membership differs
|
|
90
|
-
* per collaborator) and a committed file is a per-REPO answer. As a filter it
|
|
91
|
-
* is correct; as an authority it would be a way for a forked repo to point your
|
|
92
|
-
* session at someone else's org.
|
|
93
|
-
*
|
|
94
|
-
* Malformed or missing is not an error — it is the normal case.
|
|
95
|
-
*/
|
|
96
|
-
export function readDeclaration(root) {
|
|
97
|
-
try {
|
|
98
|
-
const raw = readFileSync(join(root, '.drafted', 'config.json'), 'utf8');
|
|
99
|
-
const parsed = JSON.parse(raw);
|
|
100
|
-
if (!parsed || typeof parsed !== 'object') return null;
|
|
101
|
-
const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
102
|
-
const declared = { org: pick(parsed.org), folder: pick(parsed.folder), gitUrl: pick(parsed.gitUrl) };
|
|
103
|
-
return declared.org || declared.folder ? declared : null;
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Turn the server's candidate list into one address, or an honest non-answer.
|
|
111
|
-
*
|
|
112
|
-
* States, and why each exists:
|
|
113
|
-
* none — not a git repo. Behaves exactly as today.
|
|
114
|
-
* unlinked — a remote exists and matched nothing. An EXPLICIT negative: a
|
|
115
|
-
* bare "not linked" is indistinguishable from "I failed to parse
|
|
116
|
-
* your remote", and that ambiguity is this feature's most likely
|
|
117
|
-
* silent failure, so the probed URLs are always echoed.
|
|
118
|
-
* linked — exactly one candidate, or the declaration picked one.
|
|
119
|
-
* ambiguous — several member orgs link this repo. Adopt NOTHING. This is the
|
|
120
|
-
* case where a wrong write is both most plausible and least
|
|
121
|
-
* detectable, so it is never guessed.
|
|
122
|
-
*/
|
|
123
|
-
export function resolveAddress(probe, matches = []) {
|
|
124
|
-
if (!probe) return { state: 'none' };
|
|
125
|
-
const base = {
|
|
126
|
-
root: probe.root,
|
|
127
|
-
branch: probe.branch,
|
|
128
|
-
remote: probe.remotes[0]?.url ?? null,
|
|
129
|
-
};
|
|
130
|
-
if (!matches.length) {
|
|
131
|
-
return { ...base, state: 'unlinked', probed: probe.remotes.map((r) => r.url) };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const picked = matches.length === 1 ? matches[0] : selectByDeclaration(probe.declared, matches);
|
|
135
|
-
if (!picked) {
|
|
136
|
-
return { ...base, state: 'ambiguous', candidates: matches.map((m) => m.path) };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// The branch is reported, never enforced: reading a folder whose index tracks
|
|
140
|
-
// another branch is legitimate, it just has to be visible.
|
|
141
|
-
const onTrackedBranch = !probe.branch || picked.branch === probe.branch;
|
|
142
|
-
return {
|
|
143
|
-
...base,
|
|
144
|
-
state: 'linked',
|
|
145
|
-
path: picked.path,
|
|
146
|
-
org: picked.orgName,
|
|
147
|
-
orgId: picked.orgId,
|
|
148
|
-
orgSlug: picked.orgSlug,
|
|
149
|
-
folder: picked.folderName,
|
|
150
|
-
trackedBranch: picked.branch,
|
|
151
|
-
onTrackedBranch,
|
|
152
|
-
viaDeclaration: matches.length > 1,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Match a declaration against candidates by org name/slug and folder name.
|
|
157
|
-
* Returns null unless it selects exactly one — an ambiguous declaration is no
|
|
158
|
-
* better than no declaration, and must not break the tie by accident. */
|
|
159
|
-
function selectByDeclaration(declared, matches) {
|
|
160
|
-
if (!declared) return null;
|
|
161
|
-
const eq = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
|
|
162
|
-
const hits = matches.filter(
|
|
163
|
-
(m) =>
|
|
164
|
-
(!declared.org || eq(declared.org, m.orgName) || eq(declared.org, m.orgSlug)) &&
|
|
165
|
-
(!declared.folder || eq(declared.folder, m.folderName))
|
|
166
|
-
);
|
|
167
|
-
return hits.length === 1 ? hits[0] : null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** The prose an agent reads. Written to TEACH, like the 409 repo_owned gate:
|
|
171
|
-
* it names the address to use and the fact that other orgs stay reachable. */
|
|
172
|
-
export function addressNote(address, workingOrgId) {
|
|
173
|
-
if (!address || address.state === 'none') return null;
|
|
174
|
-
if (address.state === 'unknown') {
|
|
175
|
-
// Say WHICH question went unanswered. "Not linked" and "could not ask" look
|
|
176
|
-
// identical to an agent, and only one of them means it is safe to proceed.
|
|
177
|
-
return `This is a git checkout (${address.remote || 'no remote'}) but Drafted could not be asked whether it is linked (${address.reason}). Nothing is defaulted — address writes explicitly, and re-check with whoami once signed in.`;
|
|
178
|
-
}
|
|
179
|
-
if (address.state === 'unlinked') {
|
|
180
|
-
return `This checkout (${address.probed.join(', ') || 'no remote'}) is not linked to any Drafted folder you can see. Nothing is defaulted — address writes explicitly.`;
|
|
181
|
-
}
|
|
182
|
-
if (address.state === 'ambiguous') {
|
|
183
|
-
return `This checkout is linked in more than one org you belong to (${address.candidates.join(', ')}). Nothing is defaulted — pass org= or an absolute /o/<org>/... path.`;
|
|
184
|
-
}
|
|
185
|
-
const parts = [`Your working directory is the repo linked to ${address.path}.`];
|
|
186
|
-
if (workingOrgId && workingOrgId !== address.orgId) {
|
|
187
|
-
parts.push(`This session's working org is a DIFFERENT org — address work under ${address.path}/... unless the user asked for another org.`);
|
|
188
|
-
}
|
|
189
|
-
if (!address.onTrackedBranch) {
|
|
190
|
-
parts.push(`Note: that folder tracks branch ${address.trackedBranch} and you are on ${address.branch}, so its index describes another branch.`);
|
|
191
|
-
}
|
|
192
|
-
parts.push(`That folder's wiki and skills are git-owned: writes return 409 repo_owned and belong in a commit. Other orgs stay reachable with org= or /o/<org>/... .`);
|
|
193
|
-
return parts.join(' ');
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Self-check: parsing and resolution, no git and no network.
|
|
197
|
-
export function demo() {
|
|
198
|
-
const ok = (c, m) => { if (!c) throw new Error(m); };
|
|
199
|
-
const remotes = parseRemotes('upstream\tgit@github.com:a/b.git (fetch)\nupstream\tx (push)\norigin\thttps://github.com/c/d (fetch)');
|
|
200
|
-
ok(remotes[0].name === 'origin', 'origin ranks first');
|
|
201
|
-
ok(remotes.length === 2, 'deduped to two remotes');
|
|
202
|
-
|
|
203
|
-
ok(resolveAddress(null).state === 'none', 'no repo -> none');
|
|
204
|
-
const probe = { root: '/r', branch: 'main', remotes: [{ name: 'origin', url: 'u' }], declared: null };
|
|
205
|
-
ok(resolveAddress(probe, []).state === 'unlinked', 'no matches -> unlinked');
|
|
206
|
-
|
|
207
|
-
const one = { path: '/o/x/F', orgName: 'X', orgSlug: 'x', orgId: 'o1', folderName: 'F', branch: 'main' };
|
|
208
|
-
ok(resolveAddress(probe, [one]).state === 'linked', 'single match -> linked');
|
|
209
|
-
|
|
210
|
-
const two = { ...one, path: '/o/y/G', orgName: 'Y', orgSlug: 'y', orgId: 'o2', folderName: 'G' };
|
|
211
|
-
ok(resolveAddress(probe, [one, two]).state === 'ambiguous', 'two matches -> ambiguous');
|
|
212
|
-
ok(resolveAddress({ ...probe, declared: { org: 'Y' } }, [one, two]).path === '/o/y/G', 'declaration breaks the tie');
|
|
213
|
-
ok(resolveAddress({ ...probe, declared: { org: 'Z' } }, [one, two]).state === 'ambiguous', 'declaration naming a non-candidate grants nothing');
|
|
214
|
-
console.log('repo-address self-check OK');
|
|
215
|
-
}
|
|
@@ -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);
|