drafted 1.19.16 → 1.19.18
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/cli/drafted.mjs +37 -73
- package/cli/repo-scan.mjs +90 -6
- package/mcp/server.mjs +98 -52
- package/package.json +1 -1
- package/src/shared/folder-path.mjs +160 -0
package/cli/drafted.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { homedir, tmpdir, platform } from 'os';
|
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { DESIGN_SYSTEM_PROMPT, buildDesignPrompt } from './prompts.mjs';
|
|
19
|
-
import { scanRepoAgents, detectDefaultBranch, detectRemoteUrl, isGitUrl, cloneToTemp } from './repo-scan.mjs';
|
|
19
|
+
import { scanRepoAgents, detectDefaultBranch, detectRemoteUrl, isGitUrl, cloneToTemp, classifySeed } from './repo-scan.mjs';
|
|
20
20
|
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
22
|
const __dirname = dirname(__filename);
|
|
@@ -301,9 +301,14 @@ async function readApiGet(command, apiPath, org) {
|
|
|
301
301
|
// so a 401/403 here means the credential is genuinely missing/expired — surface
|
|
302
302
|
// an actionable next step instead of the bare server "Unauthenticated" string.
|
|
303
303
|
if (res.status === 401 || res.status === 403) {
|
|
304
|
+
// Name BOTH paths. `drafted login` is interactive, so an agent that reads
|
|
305
|
+
// only this line hands its user a shell command instead of a link — which
|
|
306
|
+
// is what happened. An agent should call the MCP auth tool and surface the
|
|
307
|
+
// URL it returns; approval writes the shared auth file, fixing the CLI too.
|
|
304
308
|
msg = `Not authenticated for ${serverUrl} — your Drafted session is missing or expired. `
|
|
305
|
-
+ `
|
|
306
|
-
+ `
|
|
309
|
+
+ `If you are a person at a terminal: run \`drafted login\`. `
|
|
310
|
+
+ `If you are an agent: call the Drafted MCP auth tool with action="get_link" and give the URL to your user — you cannot complete a sign-in yourself. `
|
|
311
|
+
+ `Either way the approval lands in ${DEFAULT_AUTH_FILE}, which the CLI and MCP share.`;
|
|
307
312
|
}
|
|
308
313
|
jsonOut(false, command, msg);
|
|
309
314
|
console.error(`❌ ${msg}`);
|
|
@@ -2143,62 +2148,48 @@ minionCmd
|
|
|
2143
2148
|
// read-only index for browse/search. `repo add` is idempotent (re-scan on re-add),
|
|
2144
2149
|
// so content edits in git are reflected with no pin bump. No Drafted token in the
|
|
2145
2150
|
// daemon — it shells out to this verb.
|
|
2146
|
-
|
|
2151
|
+
// `repo add` and `repo remove` are gone on purpose. Linking a folder to a repo
|
|
2152
|
+
// (and detaching it) moves that folder's wiki + skills into git, makes Drafted
|
|
2153
|
+
// read-only for them, and commits someone's knowledge to someone's repository.
|
|
2154
|
+
// That is a decision a person makes in the Drafted UI — Settings > Organization
|
|
2155
|
+
// > GitHub to connect the account, then the folder menu — not something a script
|
|
2156
|
+
// or an agent does in passing. `list`, `lookup` and `rescan` remain.
|
|
2157
|
+
const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities and .wiki/ pages)');
|
|
2147
2158
|
|
|
2148
2159
|
repoCmd
|
|
2149
|
-
.command('
|
|
2150
|
-
.description('
|
|
2160
|
+
.command('list')
|
|
2161
|
+
.description('List the org\'s linked repos with their folder + git index counts (.agents/ skills/identities, .wiki/ pages)')
|
|
2151
2162
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2152
|
-
.option('--folder <name>', 'folder to link the repo into (required)')
|
|
2153
|
-
.option('--slug <slug>', 'repo slug (defaults to the repo name from the URL)')
|
|
2154
|
-
.option('--branch <name>', 'git branch to track (default: the repo default; fails loudly if the branch is missing)')
|
|
2155
|
-
.option('--description <desc>', 'short description')
|
|
2156
2163
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2157
|
-
.action(async (
|
|
2158
|
-
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
const
|
|
2162
|
-
const folderName = String(opts.folder || '').trim();
|
|
2163
|
-
if (!folderName) return fail('--folder <name> is required — repos are linked to a folder, never to the org directly');
|
|
2164
|
-
// Resolve folder name -> id in the target org.
|
|
2165
|
-
const foldersRes = await authFetch(`${server}/api/folders`, { headers: orgHeaders });
|
|
2166
|
-
const foldersJson = await foldersRes.json().catch(() => ({}));
|
|
2167
|
-
if (!foldersRes.ok) return fail(foldersJson.error || `HTTP ${foldersRes.status}`);
|
|
2168
|
-
const folders = Array.isArray(foldersJson) ? foldersJson : foldersJson.folders || [];
|
|
2169
|
-
const folder = folders.find((f) => f.name === folderName);
|
|
2170
|
-
if (!folder) return fail(`no folder named "${folderName}" in this org`);
|
|
2171
|
-
// The server fetches + scans; the CLI only resolves the association.
|
|
2172
|
-
const payload = { gitUrl: url, folderId: folder.id, slug: opts.slug, description: opts.description, branch: opts.branch };
|
|
2173
|
-
const res = await authFetch(`${server}/api/repos`, {
|
|
2174
|
-
method: 'POST',
|
|
2175
|
-
headers: { 'Content-Type': 'application/json', ...orgHeaders },
|
|
2176
|
-
body: JSON.stringify(payload),
|
|
2177
|
-
});
|
|
2178
|
-
const data = await res.json().catch(() => ({}));
|
|
2179
|
-
if (!res.ok) return fail(data.error || `HTTP ${res.status}`);
|
|
2180
|
-
const created = res.status === 201;
|
|
2181
|
-
if (opts.format === 'json') {
|
|
2182
|
-
console.log(JSON.stringify({ ok: true, command: 'repo:add', data: { ...data, created } }));
|
|
2183
|
-
} else {
|
|
2184
|
-
console.log([created ? 'added' : 'updated', data.slug || '', data.gitUrl || url, folderName, `branch:${data.branch || '-'}`, `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`].join('\t'));
|
|
2185
|
-
}
|
|
2164
|
+
.action(async (opts) => {
|
|
2165
|
+
const data = await readApiGet('repo:list', '/api/repos', opts.org);
|
|
2166
|
+
const rows = data.repos || [];
|
|
2167
|
+
if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
|
|
2168
|
+
for (const r of rows) console.log([r.slug, r.folderName || '-', r.gitUrl, `branch:${r.branch || '-'}`, `skills:${r.skillCount ?? 0}`, `identities:${r.identityCount ?? 0}`, `wiki:${r.wikiCount ?? 0}`].join('\t'));
|
|
2186
2169
|
});
|
|
2170
|
+
|
|
2187
2171
|
repoCmd
|
|
2188
|
-
.command('
|
|
2189
|
-
.description('
|
|
2172
|
+
.command('entries')
|
|
2173
|
+
.description('Search the git index across the org\'s connected repos — the .agents/ skills and identities and .wiki/ pages each one exposes')
|
|
2190
2174
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2175
|
+
.option('--query <q>', 'match against name, slug and description')
|
|
2176
|
+
.option('--kind <kind>', 'restrict to one of: skill, identity, wiki')
|
|
2191
2177
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2192
2178
|
.action(async (opts) => {
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2179
|
+
const params = new URLSearchParams();
|
|
2180
|
+
if (opts.query) params.set('q', opts.query);
|
|
2181
|
+
if (opts.kind) params.set('kind', opts.kind);
|
|
2182
|
+
const qs = params.toString();
|
|
2183
|
+
const data = await readApiGet('repo:entries', `/api/repos/entries${qs ? '?' + qs : ''}`, opts.org);
|
|
2184
|
+
const rows = data.entries || [];
|
|
2195
2185
|
if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
|
|
2196
|
-
|
|
2186
|
+
if (!rows.length) { console.log('no entries'); return; }
|
|
2187
|
+
for (const e of rows) console.log([e.kind, e.slug || e.name, `repo:${e.repoSlug || '-'}`, e.sourcePath || ''].join('\t'));
|
|
2197
2188
|
});
|
|
2198
2189
|
|
|
2199
2190
|
repoCmd
|
|
2200
2191
|
.command('rescan <slugOrId>')
|
|
2201
|
-
.description('Re-fetch the tracked branch and refresh a repo\'s .agents/
|
|
2192
|
+
.description('Re-fetch the tracked branch and refresh a repo\'s git index — .agents/ and .wiki/ (idempotent; reports staleness via lastScannedAt/Sha)')
|
|
2202
2193
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2203
2194
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2204
2195
|
.action(async (slugOrId, opts) => {
|
|
@@ -2220,34 +2211,7 @@ repoCmd
|
|
|
2220
2211
|
const data = await res.json().catch(() => ({}));
|
|
2221
2212
|
if (!res.ok) { const m = data.error || `HTTP ${res.status}`; if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:rescan', error: m })); else console.log(['error', m].join('\t')); process.exit(1); }
|
|
2222
2213
|
if (opts.format === 'json') console.log(JSON.stringify({ ok: true, command: 'repo:rescan', data }));
|
|
2223
|
-
else console.log(['rescaned', data.slug || '', data.branch || '-', `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`].join('\t'));
|
|
2224
|
-
});
|
|
2225
|
-
|
|
2226
|
-
repoCmd
|
|
2227
|
-
.command('remove <slugOrId>')
|
|
2228
|
-
.description('Remove a registered repo (cascades its index entries)')
|
|
2229
|
-
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2230
|
-
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2231
|
-
.action(async (slugOrId, opts) => {
|
|
2232
|
-
requireLogin();
|
|
2233
|
-
const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
|
|
2234
|
-
const server = getServerUrl().replace(/\/$/, '');
|
|
2235
|
-
// UUID? DELETE directly. Otherwise resolve slug -> id via the list.
|
|
2236
|
-
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
|
|
2237
|
-
let id = isUuid ? slugOrId : null;
|
|
2238
|
-
if (!id) {
|
|
2239
|
-
const lookup = await authFetch(`${server}/api/repos`, { headers: orgHeaders });
|
|
2240
|
-
const lj = await lookup.json().catch(() => ({}));
|
|
2241
|
-
if (!lookup.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: lj.error || `HTTP ${lookup.status}` })); else console.log(['error', lj.error || `HTTP ${lookup.status}`].join('\t')); process.exit(1); }
|
|
2242
|
-
const found = (lj.repos || []).find((r) => r.slug === slugOrId);
|
|
2243
|
-
if (!found) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: `no repo with slug "${slugOrId}"` })); else console.log(['error', `no repo with slug "${slugOrId}"`].join('\t')); process.exit(1); }
|
|
2244
|
-
id = found.id;
|
|
2245
|
-
}
|
|
2246
|
-
const res = await authFetch(`${server}/api/repos/${encodeURIComponent(id)}`, { method: 'DELETE', headers: orgHeaders });
|
|
2247
|
-
const data = await res.json().catch(() => ({}));
|
|
2248
|
-
if (!res.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: data.error || `HTTP ${res.status}` })); else console.log(['error', data.error || `HTTP ${res.status}`].join('\t')); process.exit(1); }
|
|
2249
|
-
if (opts.format === 'json') console.log(JSON.stringify({ ok: true, command: 'repo:remove', data: { id } }));
|
|
2250
|
-
else console.log(['removed', id].join('\t'));
|
|
2214
|
+
else console.log(['rescaned', data.slug || '', data.branch || '-', `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`, `wiki:${data.wiki ?? 0}`].join('\t'));
|
|
2251
2215
|
});
|
|
2252
2216
|
|
|
2253
2217
|
program.parse();
|
package/cli/repo-scan.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (slug + name). See docs/plans/org-substrate-normalization.md.
|
|
9
9
|
|
|
10
10
|
import { readFileSync, readdirSync, existsSync, mkdtempSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
11
|
+
import { join, relative, sep } from 'path';
|
|
12
12
|
import { tmpdir } from 'os';
|
|
13
13
|
import { execSync } from 'child_process';
|
|
14
14
|
import { parse as parseYaml } from 'yaml';
|
|
@@ -30,10 +30,12 @@ export function parseFrontmatter(text) {
|
|
|
30
30
|
// ── Scan ────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Scan <dir>/.agents/skills/<name>/SKILL.md
|
|
34
|
-
* identity.yaml. Returns { skills, identities }
|
|
35
|
-
* pointers only — no file content). A skill with no
|
|
36
|
-
* (name falls back to the dir). Identities index
|
|
33
|
+
* Scan <dir>/.agents/skills/<name>/SKILL.md, <dir>/.agents/identities/<slug>/
|
|
34
|
+
* identity.yaml and <dir>/.wiki/**.md. Returns { skills, identities, wiki }
|
|
35
|
+
* catalog entries (metadata + pointers only — no file content). A skill with no
|
|
36
|
+
* frontmatter still indexes (name falls back to the dir). Identities index
|
|
37
|
+
* {slug, name} from identity.yaml. A wiki page's slug is its `.wiki/`-relative
|
|
38
|
+
* path with `.md` stripped, which is exactly its Drafted wiki path.
|
|
37
39
|
*/
|
|
38
40
|
export function scanRepoAgents(dir) {
|
|
39
41
|
const skills = [];
|
|
@@ -84,7 +86,54 @@ export function scanRepoAgents(dir) {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
return { skills, identities };
|
|
89
|
+
return { skills, identities, wiki: scanRepoWiki(dir) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Scan <dir>/.wiki/**.md. One entry per page; `index.md` is skipped at every
|
|
94
|
+
* level because Drafted SYNTHESIZES it on read (a reserved path, never a stored
|
|
95
|
+
* page) — indexing it would shadow the synthesis with a stale copy.
|
|
96
|
+
*/
|
|
97
|
+
export function scanRepoWiki(dir) {
|
|
98
|
+
const root = join(dir, '.wiki');
|
|
99
|
+
if (!existsSync(root)) return [];
|
|
100
|
+
const pages = [];
|
|
101
|
+
for (const file of walkMarkdown(root)) {
|
|
102
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
103
|
+
if (rel.split('/').pop() === 'index.md') continue;
|
|
104
|
+
const text = readFileSync(file, 'utf8');
|
|
105
|
+
const fm = parseFrontmatter(text) || {};
|
|
106
|
+
const slug = rel.replace(/\.md$/, '');
|
|
107
|
+
pages.push({
|
|
108
|
+
kind: 'wiki',
|
|
109
|
+
name: String(fm.title || firstHeading(text) || slug.split('/').pop()),
|
|
110
|
+
slug,
|
|
111
|
+
description: fm.description != null ? String(fm.description) : '',
|
|
112
|
+
sourcePath: `.wiki/${rel}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return pages.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function firstHeading(text) {
|
|
119
|
+
const m = text.match(/^#\s+(.+)$/m);
|
|
120
|
+
return m ? m[1].trim() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recursively yield every `.md` file under dir, skipping dot-entries. */
|
|
124
|
+
function* walkMarkdown(dir) {
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
if (e.name.startsWith('.')) continue;
|
|
133
|
+
const full = join(dir, e.name);
|
|
134
|
+
if (e.isDirectory()) yield* walkMarkdown(full);
|
|
135
|
+
else if (e.isFile() && e.name.endsWith('.md')) yield full;
|
|
136
|
+
}
|
|
88
137
|
}
|
|
89
138
|
|
|
90
139
|
function readdirSafe(dir) {
|
|
@@ -95,6 +144,41 @@ function readdirSafe(dir) {
|
|
|
95
144
|
}
|
|
96
145
|
}
|
|
97
146
|
|
|
147
|
+
// ── Seed-on-link ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Classify what seeding a folder's wiki + skills onto a branch would do.
|
|
151
|
+
*
|
|
152
|
+
* Linking is a MIGRATION, not a flag flip: the folder's existing content must
|
|
153
|
+
* land in git BEFORE the folder flips to repo-owned, or every page and skill it
|
|
154
|
+
* already holds becomes unauthorable and shadowed. So a path already on the
|
|
155
|
+
* branch is a CONFLICT — refuse the link and list them — unless the content is
|
|
156
|
+
* identical, which makes it a no-op and keeps `link -> detach -> link` idempotent.
|
|
157
|
+
*
|
|
158
|
+
* @param {Map<string,string>|object} existing branch path -> file content
|
|
159
|
+
* @param {{path:string, content:string}[]} seed what the folder would write
|
|
160
|
+
* @returns {{creates:string[], identical:string[], conflicts:string[]}}
|
|
161
|
+
*/
|
|
162
|
+
export function classifySeed(existing, seed) {
|
|
163
|
+
const get = existing instanceof Map ? (p) => existing.get(p) : (p) => existing?.[p];
|
|
164
|
+
const out = { creates: [], identical: [], conflicts: [] };
|
|
165
|
+
for (const f of seed || []) {
|
|
166
|
+
const there = get(f.path);
|
|
167
|
+
if (there === undefined || there === null) out.creates.push(f.path);
|
|
168
|
+
else if (sameContent(there, f.content)) out.identical.push(f.path);
|
|
169
|
+
else out.conflicts.push(f.path);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ponytail: byte equality modulo line endings and trailing blank space. Enough
|
|
175
|
+
// to make a re-link a no-op; if a semantic (frontmatter-order) diff ever matters,
|
|
176
|
+
// compare parsed OKF instead.
|
|
177
|
+
function sameContent(a, b) {
|
|
178
|
+
const norm = (s) => String(s ?? '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
|
179
|
+
return norm(a) === norm(b);
|
|
180
|
+
}
|
|
181
|
+
|
|
98
182
|
/** Best-effort default branch of a git checkout. 'main' if git is unavailable. */
|
|
99
183
|
export function detectDefaultBranch(dir) {
|
|
100
184
|
try {
|
package/mcp/server.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { z } from 'zod';
|
|
|
19
19
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
|
+
import { liftFolderScope, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
|
|
22
23
|
import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
24
|
import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
|
|
24
25
|
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
@@ -273,6 +274,8 @@ export function splitOrgScope(raw) {
|
|
|
273
274
|
return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
const ROOT_HINT = '<base>/wiki, <base>/skills, <base>/tasks, <base>/projects';
|
|
278
|
+
|
|
276
279
|
// Accept a FULL share URL (https://drafted.live/o/...?...) — humans paste links,
|
|
277
280
|
// not paths. The pathname of a Drafted URL IS the fs path (Q2: one canonical link
|
|
278
281
|
// for both consumers), so stripping the origin leaves an addressable path. Falls
|
|
@@ -1072,6 +1075,13 @@ function workingOrgId() {
|
|
|
1072
1075
|
return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
|
|
1073
1076
|
}
|
|
1074
1077
|
|
|
1078
|
+
// One `ls` level of folders, from the org's folder list. Defined inside the
|
|
1079
|
+
// closure because `api` is — it carries this session's auth and org scope.
|
|
1080
|
+
async function listSubfolders(base, prefix, orgHeader) {
|
|
1081
|
+
const rows = await api('GET', '/api/folders', undefined, orgHeader).catch(() => []);
|
|
1082
|
+
return subfolderEntries(base, prefix, (Array.isArray(rows) ? rows : []).map((r) => r?.name));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1075
1085
|
async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
|
|
1076
1086
|
await ensureSession();
|
|
1077
1087
|
const pid = getState().projectId;
|
|
@@ -2072,12 +2082,33 @@ async function waitForBootstrapAuth(deadline, staleSid) {
|
|
|
2072
2082
|
return null;
|
|
2073
2083
|
}
|
|
2074
2084
|
|
|
2075
|
-
if (!isRemote) tool('auth', 'Sign in to Drafted
|
|
2085
|
+
if (!isRemote) tool('auth', 'Sign in to Drafted.\n\n`action=get_link` ALWAYS returns a sign-in URL. This is the action to use when you are an agent: you cannot complete a sign-in, so put the URL in your reply and stop — the human opens it in their own browser, approves, and your next Drafted call picks up the session. Approval also writes ~/.drafted/auth.json, so the `drafted` CLI is signed in too.\n\n`action=login` signs in and WAITS for it to land, opening the desktop app when one is installed. Use it only when a human is watching this terminal.\n\n(The web MCP uses OAuth2 and does not have this tool.)', {
|
|
2076
2086
|
action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
|
|
2077
2087
|
}, async ({ action }) => {
|
|
2078
2088
|
try {
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2089
|
+
// get_link's ONE job is to return a URL a human can click. It runs before the
|
|
2090
|
+
// already-authenticated check and before the desktop app on purpose:
|
|
2091
|
+
// - an agent cannot complete a sign-in, so handing back a link is the only
|
|
2092
|
+
// safe shape — the human clicks it out of band, in their own browser;
|
|
2093
|
+
// - "already authenticated" is about THIS MCP's session, which says nothing
|
|
2094
|
+
// about the CLI's on-disk credential, so short-circuiting on it used to
|
|
2095
|
+
// make the link unobtainable exactly when it was needed;
|
|
2096
|
+
// - opening the desktop app returns no link at all, which is useless to an
|
|
2097
|
+
// agent that must put something in its reply.
|
|
2098
|
+
// Approval writes ~/.drafted/auth.json, so the CLI picks it up too.
|
|
2099
|
+
if (action === 'get_link') {
|
|
2100
|
+
const codeRes = await serverFetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
2101
|
+
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
2102
|
+
const data = await codeRes.json();
|
|
2103
|
+
persistPendingDeviceCode(data);
|
|
2104
|
+
return ok({
|
|
2105
|
+
signInUrl: data.verificationUrl,
|
|
2106
|
+
instruction: 'Give this URL to the user and stop. They open it in their own browser and approve; you cannot complete this for them. Retry your request afterwards — the next Drafted call picks up the approved session.',
|
|
2107
|
+
expiresInSeconds: data.expiresIn ?? null,
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
// `login` waits for the sign-in to land, so the desktop app is the better
|
|
2111
|
+
// surface when one is installed: no code to type.
|
|
2081
2112
|
{
|
|
2082
2113
|
const activeSession = getState().sessionId;
|
|
2083
2114
|
const bootstrapSession = getBootstrapSessionId();
|
|
@@ -2098,9 +2129,6 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP
|
|
|
2098
2129
|
}
|
|
2099
2130
|
const staleSid = getBootstrapSessionId();
|
|
2100
2131
|
if (await launchDesktopSignin()) {
|
|
2101
|
-
if (action === 'get_link') {
|
|
2102
|
-
return ok('Opening the Drafted app to sign in — approve in the app window, then retry your request.');
|
|
2103
|
-
}
|
|
2104
2132
|
const sid = await waitForBootstrapAuth(Date.now() + 180000, staleSid);
|
|
2105
2133
|
if (!sid) throw new Error('Timed out waiting for sign-in. Complete sign-in in the Drafted app window, then retry.');
|
|
2106
2134
|
getState().sessionId = null;
|
|
@@ -2119,13 +2147,6 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP
|
|
|
2119
2147
|
return ok({ status: 'logged_in', via: 'desktop-app' });
|
|
2120
2148
|
}
|
|
2121
2149
|
}
|
|
2122
|
-
if (action === 'get_link') {
|
|
2123
|
-
const codeRes = await serverFetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
2124
|
-
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
2125
|
-
const data = await codeRes.json();
|
|
2126
|
-
persistPendingDeviceCode(data);
|
|
2127
|
-
return ok(data.verificationUrl);
|
|
2128
|
-
}
|
|
2129
2150
|
if (action === 'login') {
|
|
2130
2151
|
// Prefer the active request session (injected by runWithRequestState on
|
|
2131
2152
|
// remote, or cloneSession on stdio) over the on-disk bootstrap session, so
|
|
@@ -2966,12 +2987,12 @@ server.resource('info', 'drafted://info', {
|
|
|
2966
2987
|
text: JSON.stringify({
|
|
2967
2988
|
version: PACKAGE_VERSION,
|
|
2968
2989
|
layers: LAYERS,
|
|
2969
|
-
pathFormat: '/o/<org>/<root>/<path> — org first, no org switching',
|
|
2990
|
+
pathFormat: '/o/<org>/<folder…?>/<root>/<path> — org first, no org switching; a folder chain may precede any root',
|
|
2970
2991
|
roots: {
|
|
2971
|
-
wiki: '/o/<org
|
|
2972
|
-
skills: '/o/<org
|
|
2973
|
-
tasks: '/o/<org
|
|
2974
|
-
projects: '/o/<org
|
|
2992
|
+
wiki: '/o/<org>/<folder…?>/wiki/<path>',
|
|
2993
|
+
skills: '/o/<org>/<folder…?>/skills/<slug>',
|
|
2994
|
+
tasks: '/o/<org>/<folder…?>/tasks/<lane?>/<file>',
|
|
2995
|
+
projects: '/o/<org>/<folder…?>/projects/<project>/<layer>/<lane>/<file>',
|
|
2975
2996
|
},
|
|
2976
2997
|
tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'tour', 'screenshot', 'minion'],
|
|
2977
2998
|
}, null, 2),
|
|
@@ -2979,9 +3000,9 @@ server.resource('info', 'drafted://info', {
|
|
|
2979
3000
|
};
|
|
2980
3001
|
});
|
|
2981
3002
|
|
|
2982
|
-
tool('fs', 'Navigate Drafted like a local filesystem.
|
|
3003
|
+
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
|
|
2983
3004
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
2984
|
-
path: z.string().describe('Drafted path: /o/<org
|
|
3005
|
+
path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
|
|
2985
3006
|
to: z.string().optional().describe('[mv] destination path; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
2986
3007
|
url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
|
|
2987
3008
|
query: z.string().optional().describe('[search] term to match against names/content'),
|
|
@@ -3025,7 +3046,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3025
3046
|
}
|
|
3026
3047
|
to = toScoped.path;
|
|
3027
3048
|
}
|
|
3028
|
-
|
|
3049
|
+
// Folder chain (Wave 0): folder is the single recursive container and the org
|
|
3050
|
+
// is that node at depth 0, so EVERY level carries the same four roots. The
|
|
3051
|
+
// root keyword is the separator — everything before wiki/skills/tasks/projects
|
|
3052
|
+
// is the folder chain, everything after is the path within that root. The
|
|
3053
|
+
// folder rides the REQUEST (X-Drafted-Folder); it is never stored, because a
|
|
3054
|
+
// "current folder" would be the retired org cursor rebuilt one level down.
|
|
3055
|
+
const pScope = liftFolderScope(p, to);
|
|
3056
|
+
if (pScope.error) return err(new Error(`${action} ${pScope.error}`));
|
|
3057
|
+
const folder = pScope.folder;
|
|
3058
|
+
to = pScope.to;
|
|
3059
|
+
const orgHeader = {
|
|
3060
|
+
...(org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {})),
|
|
3061
|
+
...(folder ? { 'X-Drafted-Folder': folder } : {}),
|
|
3062
|
+
};
|
|
3063
|
+
// The folder chain has been lifted into the header — everything below
|
|
3064
|
+
// dispatches on the ROOT, at any depth, with no per-root folder handling.
|
|
3065
|
+
const folderBase = `${orgFromPath ? `/o/${orgFromPath}` : ''}${folder ? '/' + folder : ''}`;
|
|
3066
|
+
if (pScope.rest) p = pScope.rest;
|
|
3029
3067
|
const gs = getSessionState().gates;
|
|
3030
3068
|
|
|
3031
3069
|
// ── Root listing: / or empty → the orgs this session can address (Shape A) ──
|
|
@@ -3060,7 +3098,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3060
3098
|
|
|
3061
3099
|
section('Wiki', wiki, (v) => {
|
|
3062
3100
|
const hits = (v?.hits || v?.pages || v?.results || []).slice(0, 10);
|
|
3063
|
-
return hits.length ? hits.map(h => ` ${scope}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
|
|
3101
|
+
return hits.length ? hits.map(h => ` ${scope}${h.folder ? `/${h.folder}` : ''}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
|
|
3064
3102
|
});
|
|
3065
3103
|
section('Skills', skills, (v) => {
|
|
3066
3104
|
// Fewer than the other legs on purpose: skills search is fuzzy and its tail
|
|
@@ -3091,13 +3129,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3091
3129
|
}
|
|
3092
3130
|
if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, /o/<org>/tasks, or /o/<org>/projects'));
|
|
3093
3131
|
if (orgFromPath) {
|
|
3094
|
-
// ls /o/<org> →
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
{ name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
|
|
3098
|
-
{ name: 'tasks', type: 'directory', path: `/o/${orgFromPath}/tasks`, hint: 'work items — a task is a frame; status/assignee are front matter on read' },
|
|
3099
|
-
{ name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
|
|
3100
|
-
]);
|
|
3132
|
+
// ls /o/<org> → the org IS the depth-0 folder: its four roots, plus the
|
|
3133
|
+
// folders nested inside it (each carrying the same four).
|
|
3134
|
+
return ok([...rootEntries(`/o/${orgFromPath}`), ...await listSubfolders(`/o/${orgFromPath}`, '', orgHeader)]);
|
|
3101
3135
|
}
|
|
3102
3136
|
// ls / → the orgs (id, slug, name) the agent belongs to; org is the top folder
|
|
3103
3137
|
const orgs = await getOrgList();
|
|
@@ -3105,10 +3139,33 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3105
3139
|
return ok(orgs.map(o => ({ name: o.name || o.id, type: 'directory', path: `/o/${o.slug || o.id}`, hint: 'org — fs(ls, path="/o/<org>") shows its wiki/skills/projects' })));
|
|
3106
3140
|
}
|
|
3107
3141
|
|
|
3142
|
+
// ── A path with no root keyword names a FOLDER, at any depth ──────
|
|
3143
|
+
// /o/<org>/engineering, /o/<org>/engineering/backend, … Same shape as the org
|
|
3144
|
+
// itself: four roots plus whatever folders nest inside.
|
|
3145
|
+
if (!pScope.rest && folder) {
|
|
3146
|
+
if (action === 'ls') {
|
|
3147
|
+
return ok([...rootEntries(folderBase), ...await listSubfolders(folderBase, folder, orgHeader)]);
|
|
3148
|
+
}
|
|
3149
|
+
if (action === 'mkdir') {
|
|
3150
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3151
|
+
const created = await api('POST', '/api/folders', { name: folder }, orgHeader);
|
|
3152
|
+
return ok({ created: true, folder: created?.name || folder, id: created?.id, path: folderBase,
|
|
3153
|
+
hint: 'a folder carries its own wiki, skills, tasks and projects' });
|
|
3154
|
+
}
|
|
3155
|
+
if (action === 'rm') {
|
|
3156
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3157
|
+
const rows = await api('GET', '/api/folders', undefined, orgHeader);
|
|
3158
|
+
const row = (Array.isArray(rows) ? rows : []).find((f) => f.name === folder);
|
|
3159
|
+
if (!row) return err(new Error(`no such folder: ${folder}`));
|
|
3160
|
+
return ok(await api('DELETE', `/api/folders/${row.id}`, undefined, orgHeader));
|
|
3161
|
+
}
|
|
3162
|
+
return err(new Error(`${folderBase} is a folder — address one of its roots: ${ROOT_HINT.replace('<base>', folderBase)}`));
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3108
3165
|
// ── mkdir: create a project (a directory in the /projects root) ──
|
|
3109
3166
|
if (action === 'mkdir') {
|
|
3110
3167
|
if (!p.startsWith('/projects')) {
|
|
3111
|
-
return err(new Error('mkdir
|
|
3168
|
+
return err(new Error('mkdir creates a project (/o/<org>/projects/<name>) or a folder (/o/<org>/<folder>, which then carries its own four roots) — wiki dirs are implicit and skills are flat, so neither needs one.'));
|
|
3112
3169
|
}
|
|
3113
3170
|
// A project create is project-less: it must not guess the org.
|
|
3114
3171
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
@@ -3116,18 +3173,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3116
3173
|
if (parts.length === 0 || parts.length > 2) {
|
|
3117
3174
|
return err(new Error(`mkdir path must be /projects/<name> or /projects/<folder>/<name> — got ${p}`));
|
|
3118
3175
|
}
|
|
3119
|
-
|
|
3176
|
+
// Two spellings reach the same place: the folder chain before the root
|
|
3177
|
+
// (/o/<org>/eng/projects/x) or the legacy segment after it
|
|
3178
|
+
// (/o/<org>/projects/eng/x). The chain wins when both are given.
|
|
3179
|
+
const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
|
|
3120
3180
|
const projectRef = parts[parts.length - 1];
|
|
3121
3181
|
const existing = await resolveProjectRef(projectRef).catch(() => null);
|
|
3122
3182
|
if (existing) return err(new Error(`project already exists: ${projectRef}`));
|
|
3123
3183
|
const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
3124
3184
|
const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
|
|
3125
3185
|
if (!created?.id) return err(new Error('project create returned no id'));
|
|
3126
|
-
if (
|
|
3127
|
-
try { await api('PATCH', `/api/project/${created.id}`, { folder }, orgHeader); } catch { /* folder best-effort */ }
|
|
3186
|
+
if (projectFolder) {
|
|
3187
|
+
try { await api('PATCH', `/api/project/${created.id}`, { folder: projectFolder }, orgHeader); } catch { /* folder best-effort */ }
|
|
3128
3188
|
}
|
|
3129
3189
|
projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
|
|
3130
|
-
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${
|
|
3190
|
+
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${folderBase}/projects/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
|
|
3131
3191
|
}
|
|
3132
3192
|
|
|
3133
3193
|
// ── Root: /wiki/... ────────────────────────────────────────────
|
|
@@ -3753,12 +3813,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3753
3813
|
// Agents reach the repos table through this tool. Content stays in git;
|
|
3754
3814
|
// Drafted keeps a read-only index. `add`/`rescan` mutate; `list`/`entries`
|
|
3755
3815
|
// are read-only and paginated with a compact mode (collection rule).
|
|
3756
|
-
tool('repo', 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills (git owns content, Drafted renders + indexes). Dispatch by `action`:\n- `list` — the org\'s linked repos (paginated, compact mode).\n- `
|
|
3757
|
-
action: z.enum(['list', '
|
|
3758
|
-
|
|
3759
|
-
folder: z.string().optional().describe('[add] folder name to link the repo into (required for add; repos are folder-scoped).'),
|
|
3760
|
-
branch: z.string().optional().describe('[add] git branch to track (default: the repo default; fails loudly if the branch is missing).'),
|
|
3761
|
-
slug: z.string().optional().describe('[add] repo slug (defaults to the repo name from the URL); [rescan] the repo slug or UUID.'),
|
|
3816
|
+
tool('repo', 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills (git owns content, Drafted renders + indexes). Dispatch by `action`:\n- `list` — the org\'s linked repos (paginated, compact mode).\n- `entries` — search the index across all the org\'s repos (skills + identities).\n- `rescan` — re-fetch the tracked branch and refresh the index.\n\nLINKING AND UNLINKING ARE NOT HERE. A person does them in the Drafted UI (Settings > Organization > GitHub to connect an account, then the folder menu), because linking moves a folder\'s wiki + skills into git and makes Drafted read-only for them. Agents read and rescan; they do not change who owns the content.\n\nAuthoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo — commit to git instead.', {
|
|
3817
|
+
action: z.enum(['list', 'entries', 'rescan']).describe('list: the org\'s connected repos; entries: search the git index across them; rescan: re-fetch a tracked branch and refresh its index.'),
|
|
3818
|
+
slug: z.string().optional().describe('[rescan] the repo slug or UUID.'),
|
|
3762
3819
|
repoId: z.string().optional().describe('[rescan] repo UUID (alternative to slug).'),
|
|
3763
3820
|
query: z.string().optional().describe('[entries] search term (matches name/description/slug).'),
|
|
3764
3821
|
kind: z.enum(['skill', 'identity']).optional().describe('[entries] filter to skill or identity.'),
|
|
@@ -3767,7 +3824,7 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
|
|
|
3767
3824
|
compact: z.boolean().optional().describe('list/entries: return only identity fields {slug,name,gitUrl} / {slug,name,kind} (default false).'),
|
|
3768
3825
|
org: z.string().optional().describe('Org (id or name) to scope this call; defaults to the session\'s working org.'),
|
|
3769
3826
|
}, async (args) => {
|
|
3770
|
-
const { action,
|
|
3827
|
+
const { action, slug, repoId, query, kind, limit, offset, compact, org } = args;
|
|
3771
3828
|
const orgHeader = org ? { 'X-Drafted-Org': org } : {};
|
|
3772
3829
|
const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
|
|
3773
3830
|
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
|
@@ -3783,17 +3840,6 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
|
|
|
3783
3840
|
}))),
|
|
3784
3841
|
});
|
|
3785
3842
|
}
|
|
3786
|
-
if (action === 'add') {
|
|
3787
|
-
if (!gitUrl) return err(new Error('add requires gitUrl (e.g. https://github.com/owner/repo)'));
|
|
3788
|
-
if (!folder) return err(new Error('add requires folder (the folder name to link the repo into — repos are folder-scoped)'));
|
|
3789
|
-
// Resolve folder name -> id in the target org.
|
|
3790
|
-
const foldersRes = await api('GET', '/api/folders', undefined, orgHeader);
|
|
3791
|
-
const folders = Array.isArray(foldersRes) ? foldersRes : (foldersRes?.folders || []);
|
|
3792
|
-
const f = folders.find((x) => x.name === folder);
|
|
3793
|
-
if (!f) return err(new Error(`no folder named "${folder}" in this org`));
|
|
3794
|
-
const result = await api('POST', '/api/repos', { gitUrl, folderId: f.id, branch: branch || undefined, slug: slug || undefined }, orgHeader);
|
|
3795
|
-
return ok({ slug: result.slug, gitUrl: result.gitUrl, branch: result.branch, skills: result.skills, identities: result.identities });
|
|
3796
|
-
}
|
|
3797
3843
|
if (action === 'rescan') {
|
|
3798
3844
|
const id = repoId || await (async () => {
|
|
3799
3845
|
if (!slug) return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.18",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder is the single recursive container: the org is that node at depth 0, and
|
|
3
|
+
* EVERY folder level carries the same four roots (wiki, skills, tasks, projects).
|
|
4
|
+
*
|
|
5
|
+
* /o/<org>/ folder (depth 0)
|
|
6
|
+
* wiki/ skills/ tasks/ projects/
|
|
7
|
+
* engineering/ folder
|
|
8
|
+
* wiki/ skills/ tasks/ projects/
|
|
9
|
+
*
|
|
10
|
+
* The grammar is unambiguous by construction: the ROOT KEYWORD IS THE SEPARATOR.
|
|
11
|
+
* Everything before the first `wiki`/`skills`/`tasks`/`projects` segment is the
|
|
12
|
+
* folder chain; everything after is the path within that root. So
|
|
13
|
+
* `/wiki/engineering/foo` is the org wiki's nested page and
|
|
14
|
+
* `/engineering/wiki/foo` is the `engineering` folder's — distinct strings, no
|
|
15
|
+
* collision. The one constraint this imposes: the four names are RESERVED and a
|
|
16
|
+
* folder may not take one.
|
|
17
|
+
*
|
|
18
|
+
* A folder is ADDRESSED, never remembered: the chain travels in the path (and
|
|
19
|
+
* from there in a request-local `X-Drafted-Folder` header), so no shared row
|
|
20
|
+
* carries a "current folder". A cursor here would be the retired
|
|
21
|
+
* `sessions.org_id` mistake rebuilt one level down — see the Drafted wiki page
|
|
22
|
+
* `engineering/org-addressing-model`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const ROOTS = ['wiki', 'skills', 'tasks', 'projects'];
|
|
26
|
+
const RESERVED = new Set(ROOTS);
|
|
27
|
+
|
|
28
|
+
export function isReservedFolderName(name) {
|
|
29
|
+
return RESERVED.has(String(name || '').trim().toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** '' (org root) or 'a/b' — no leading/trailing slashes, no empty segments. */
|
|
33
|
+
export function normalizeFolder(raw) {
|
|
34
|
+
return String(raw || '')
|
|
35
|
+
.split('/')
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** null when the folder path is usable, else the reason it is not. */
|
|
42
|
+
export function folderPathError(raw) {
|
|
43
|
+
const segs = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
|
|
44
|
+
if (!segs.length) return 'Folder name is required';
|
|
45
|
+
for (const s of segs) {
|
|
46
|
+
if (isReservedFolderName(s)) {
|
|
47
|
+
return `"${s}" is a reserved name — wiki, skills, tasks and projects are the four roots every folder carries, so a folder cannot be called one`;
|
|
48
|
+
}
|
|
49
|
+
if (s.length > 120) return `Folder name too long: "${s.slice(0, 40)}…"`;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Split a path into its folder chain and the root-relative remainder.
|
|
56
|
+
*
|
|
57
|
+
* /engineering/backend/wiki/foo → { folder: 'engineering/backend', rest: '/wiki/foo' }
|
|
58
|
+
* /wiki/engineering/foo → { folder: '', rest: '/wiki/engineering/foo' }
|
|
59
|
+
* /engineering → { folder: 'engineering', rest: '' }
|
|
60
|
+
* / → { folder: '', rest: '' }
|
|
61
|
+
*
|
|
62
|
+
* `rest` is '' when the path names a folder itself (no root keyword in it).
|
|
63
|
+
*/
|
|
64
|
+
export function splitFolderScope(path) {
|
|
65
|
+
const segs = String(path || '').split('/').filter(Boolean);
|
|
66
|
+
const at = segs.findIndex((s) => RESERVED.has(s));
|
|
67
|
+
if (at === -1) return { folder: segs.join('/'), rest: '' };
|
|
68
|
+
return { folder: segs.slice(0, at).join('/'), rest: '/' + segs.slice(at).join('/') };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Re-join a folder chain and a root-relative path into one addressable path. */
|
|
72
|
+
export function joinFolderScope(folder, rest) {
|
|
73
|
+
const f = normalizeFolder(folder);
|
|
74
|
+
const r = String(rest || '').replace(/^\/+/, '');
|
|
75
|
+
return '/' + [f, r].filter(Boolean).join('/');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The four roots as ls entries, for a folder at any depth (base = '/o/<org>[/<folder>]'). */
|
|
79
|
+
export function rootEntries(base) {
|
|
80
|
+
const b = String(base || '').replace(/\/+$/, '');
|
|
81
|
+
const hints = {
|
|
82
|
+
wiki: 'knowledge pages (markdown, OKF)',
|
|
83
|
+
skills: 'reusable procedures (flat: one dir per skill slug)',
|
|
84
|
+
tasks: 'work items — a task is a frame; status/assignee are front matter on read',
|
|
85
|
+
projects: '<project>/<layer>/<lane>/<file>',
|
|
86
|
+
};
|
|
87
|
+
return ROOTS.map((name) => ({ name, type: 'directory', path: `${b}/${name}`, hint: hints[name] }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The folder lift for an fs call: pull the folder chain off the source path (and
|
|
92
|
+
* off the destination, for mv), leaving root-relative paths the per-root
|
|
93
|
+
* dispatch can use unchanged at any depth.
|
|
94
|
+
*
|
|
95
|
+
* A move stays within one folder — silently re-homing a page across folders on
|
|
96
|
+
* a rename is the kind of quiet reorganisation that loses work.
|
|
97
|
+
*/
|
|
98
|
+
export function liftFolderScope(path, to) {
|
|
99
|
+
const src = splitFolderScope(path);
|
|
100
|
+
const folder = src.folder;
|
|
101
|
+
if (folder) {
|
|
102
|
+
const bad = folderPathError(folder);
|
|
103
|
+
if (bad) return { error: bad };
|
|
104
|
+
}
|
|
105
|
+
const out = { folder, path: src.rest || path, rest: src.rest, to };
|
|
106
|
+
if (to) {
|
|
107
|
+
const dst = splitFolderScope(to);
|
|
108
|
+
if (dst.rest) {
|
|
109
|
+
if (normalizeFolder(dst.folder) !== normalizeFolder(folder)) {
|
|
110
|
+
return { error: `stays within one folder — source is /${folder || ''} and destination is /${dst.folder || ''}` };
|
|
111
|
+
}
|
|
112
|
+
out.to = dst.rest;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The folders nested DIRECTLY inside `prefix` ('' = the org itself), given the
|
|
120
|
+
* org's folder names (each of which is its full path). One segment deeper only —
|
|
121
|
+
* `ls` shows a level, not a tree.
|
|
122
|
+
*/
|
|
123
|
+
export function subfolderEntries(base, prefix, names) {
|
|
124
|
+
const depth = prefix ? normalizeFolder(prefix).split('/').length : 0;
|
|
125
|
+
const kids = new Set();
|
|
126
|
+
for (const raw of names || []) {
|
|
127
|
+
const name = normalizeFolder(raw);
|
|
128
|
+
if (!name) continue;
|
|
129
|
+
if (prefix && !name.startsWith(normalizeFolder(prefix) + '/')) continue;
|
|
130
|
+
const segs = name.split('/');
|
|
131
|
+
if (segs.length > depth) kids.add(segs[depth]);
|
|
132
|
+
}
|
|
133
|
+
return [...kids].sort().map((name) => ({
|
|
134
|
+
name,
|
|
135
|
+
type: 'directory',
|
|
136
|
+
path: `${String(base || '').replace(/\/+$/, '')}/${name}`,
|
|
137
|
+
hint: 'folder — carries its own wiki, skills, tasks and projects',
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Process surfaces: wiki pages agents WRITE through the tool rather than read.
|
|
143
|
+
* Promote-to-commons appends to the wiki `log`, and a promotion that
|
|
144
|
+
* contradicts canon is flagged in `review`.
|
|
145
|
+
*
|
|
146
|
+
* They must be excluded from the seed AND exempted from the repo_owned guard,
|
|
147
|
+
* and those two must agree. Excluded but not exempted, they end up unauthorable
|
|
148
|
+
* AND unwritten — the tool 409s a page git never received, and promote-to-commons
|
|
149
|
+
* breaks with no obvious cause. Lives here because the CLI seeder and the server
|
|
150
|
+
* guard both need it and drift between them is exactly the failure.
|
|
151
|
+
*
|
|
152
|
+
* Whole path segments only — `reference/log-shipping` is a normal page.
|
|
153
|
+
*/
|
|
154
|
+
export const PROCESS_SURFACES = new Set(['log', 'review']);
|
|
155
|
+
|
|
156
|
+
export function isProcessSurface(path) {
|
|
157
|
+
return String(path || '')
|
|
158
|
+
.split('/')
|
|
159
|
+
.some((seg) => PROCESS_SURFACES.has(seg));
|
|
160
|
+
}
|