drafted 1.19.11 → 1.19.17
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 +30 -71
- package/cli/repo-scan.mjs +90 -6
- package/mcp/server.mjs +98 -40
- package/mcp/test-org-guards.mjs +11 -1
- 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);
|
|
@@ -2143,62 +2143,48 @@ minionCmd
|
|
|
2143
2143
|
// read-only index for browse/search. `repo add` is idempotent (re-scan on re-add),
|
|
2144
2144
|
// so content edits in git are reflected with no pin bump. No Drafted token in the
|
|
2145
2145
|
// daemon — it shells out to this verb.
|
|
2146
|
-
|
|
2146
|
+
// `repo add` and `repo remove` are gone on purpose. Linking a folder to a repo
|
|
2147
|
+
// (and detaching it) moves that folder's wiki + skills into git, makes Drafted
|
|
2148
|
+
// read-only for them, and commits someone's knowledge to someone's repository.
|
|
2149
|
+
// That is a decision a person makes in the Drafted UI — Settings > Organization
|
|
2150
|
+
// > GitHub to connect the account, then the folder menu — not something a script
|
|
2151
|
+
// or an agent does in passing. `list`, `lookup` and `rescan` remain.
|
|
2152
|
+
const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities and .wiki/ pages)');
|
|
2147
2153
|
|
|
2148
2154
|
repoCmd
|
|
2149
|
-
.command('
|
|
2150
|
-
.description('
|
|
2155
|
+
.command('list')
|
|
2156
|
+
.description('List the org\'s linked repos with their folder + git index counts (.agents/ skills/identities, .wiki/ pages)')
|
|
2151
2157
|
.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
2158
|
.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
|
-
}
|
|
2159
|
+
.action(async (opts) => {
|
|
2160
|
+
const data = await readApiGet('repo:list', '/api/repos', opts.org);
|
|
2161
|
+
const rows = data.repos || [];
|
|
2162
|
+
if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
|
|
2163
|
+
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
2164
|
});
|
|
2165
|
+
|
|
2187
2166
|
repoCmd
|
|
2188
|
-
.command('
|
|
2189
|
-
.description('
|
|
2167
|
+
.command('entries')
|
|
2168
|
+
.description('Search the git index across the org\'s connected repos — the .agents/ skills and identities and .wiki/ pages each one exposes')
|
|
2190
2169
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2170
|
+
.option('--query <q>', 'match against name, slug and description')
|
|
2171
|
+
.option('--kind <kind>', 'restrict to one of: skill, identity, wiki')
|
|
2191
2172
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2192
2173
|
.action(async (opts) => {
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2174
|
+
const params = new URLSearchParams();
|
|
2175
|
+
if (opts.query) params.set('q', opts.query);
|
|
2176
|
+
if (opts.kind) params.set('kind', opts.kind);
|
|
2177
|
+
const qs = params.toString();
|
|
2178
|
+
const data = await readApiGet('repo:entries', `/api/repos/entries${qs ? '?' + qs : ''}`, opts.org);
|
|
2179
|
+
const rows = data.entries || [];
|
|
2195
2180
|
if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
|
|
2196
|
-
|
|
2181
|
+
if (!rows.length) { console.log('no entries'); return; }
|
|
2182
|
+
for (const e of rows) console.log([e.kind, e.slug || e.name, `repo:${e.repoSlug || '-'}`, e.sourcePath || ''].join('\t'));
|
|
2197
2183
|
});
|
|
2198
2184
|
|
|
2199
2185
|
repoCmd
|
|
2200
2186
|
.command('rescan <slugOrId>')
|
|
2201
|
-
.description('Re-fetch the tracked branch and refresh a repo\'s .agents/
|
|
2187
|
+
.description('Re-fetch the tracked branch and refresh a repo\'s git index — .agents/ and .wiki/ (idempotent; reports staleness via lastScannedAt/Sha)')
|
|
2202
2188
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
2203
2189
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2204
2190
|
.action(async (slugOrId, opts) => {
|
|
@@ -2220,34 +2206,7 @@ repoCmd
|
|
|
2220
2206
|
const data = await res.json().catch(() => ({}));
|
|
2221
2207
|
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
2208
|
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'));
|
|
2209
|
+
else console.log(['rescaned', data.slug || '', data.branch || '-', `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`, `wiki:${data.wiki ?? 0}`].join('\t'));
|
|
2251
2210
|
});
|
|
2252
2211
|
|
|
2253
2212
|
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';
|
|
@@ -198,6 +199,17 @@ function scrubLocalPathMentions(description) {
|
|
|
198
199
|
// inside the factory so each HTTP request gets its own isolated server.
|
|
199
200
|
// Stdio mode uses the `mcpServer` singleton (built once at module load).
|
|
200
201
|
|
|
202
|
+
// What an fs(rm) on a project path actually targets. A layer or lane path carries
|
|
203
|
+
// no filename, so before this predicate existed it fell through to the "no file
|
|
204
|
+
// path = archive the project" branch and archived the WHOLE project — returning a
|
|
205
|
+
// normal-looking success. That is how "Ai For Coaches Talk" was lost to an
|
|
206
|
+
// fs(rm) on /slides/deck. Only the bare project path may archive.
|
|
207
|
+
export function rmScope(layer, lane, filename) {
|
|
208
|
+
if (filename) return 'file';
|
|
209
|
+
if (layer || lane) return 'directory';
|
|
210
|
+
return 'project';
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
// ── Org-ambiguity policy (the one decision core) ─────────────────
|
|
202
214
|
// The org guard inside the factory plumbs session/HTTP state into this
|
|
203
215
|
// side-effect-free predicate, which IS the policy (DRAFT-36 "one rule"). Top-level
|
|
@@ -262,6 +274,8 @@ export function splitOrgScope(raw) {
|
|
|
262
274
|
return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
|
|
263
275
|
}
|
|
264
276
|
|
|
277
|
+
const ROOT_HINT = '<base>/wiki, <base>/skills, <base>/tasks, <base>/projects';
|
|
278
|
+
|
|
265
279
|
// Accept a FULL share URL (https://drafted.live/o/...?...) — humans paste links,
|
|
266
280
|
// not paths. The pathname of a Drafted URL IS the fs path (Q2: one canonical link
|
|
267
281
|
// for both consumers), so stripping the origin leaves an addressable path. Falls
|
|
@@ -1061,6 +1075,13 @@ function workingOrgId() {
|
|
|
1061
1075
|
return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
|
|
1062
1076
|
}
|
|
1063
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
|
+
|
|
1064
1085
|
async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
|
|
1065
1086
|
await ensureSession();
|
|
1066
1087
|
const pid = getState().projectId;
|
|
@@ -2955,12 +2976,12 @@ server.resource('info', 'drafted://info', {
|
|
|
2955
2976
|
text: JSON.stringify({
|
|
2956
2977
|
version: PACKAGE_VERSION,
|
|
2957
2978
|
layers: LAYERS,
|
|
2958
|
-
pathFormat: '/o/<org>/<root>/<path> — org first, no org switching',
|
|
2979
|
+
pathFormat: '/o/<org>/<folder…?>/<root>/<path> — org first, no org switching; a folder chain may precede any root',
|
|
2959
2980
|
roots: {
|
|
2960
|
-
wiki: '/o/<org
|
|
2961
|
-
skills: '/o/<org
|
|
2962
|
-
tasks: '/o/<org
|
|
2963
|
-
projects: '/o/<org
|
|
2981
|
+
wiki: '/o/<org>/<folder…?>/wiki/<path>',
|
|
2982
|
+
skills: '/o/<org>/<folder…?>/skills/<slug>',
|
|
2983
|
+
tasks: '/o/<org>/<folder…?>/tasks/<lane?>/<file>',
|
|
2984
|
+
projects: '/o/<org>/<folder…?>/projects/<project>/<layer>/<lane>/<file>',
|
|
2964
2985
|
},
|
|
2965
2986
|
tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'tour', 'screenshot', 'minion'],
|
|
2966
2987
|
}, null, 2),
|
|
@@ -2968,9 +2989,9 @@ server.resource('info', 'drafted://info', {
|
|
|
2968
2989
|
};
|
|
2969
2990
|
});
|
|
2970
2991
|
|
|
2971
|
-
tool('fs', 'Navigate Drafted like a local filesystem.
|
|
2992
|
+
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.', {
|
|
2972
2993
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
2973
|
-
path: z.string().describe('Drafted path: /o/<org
|
|
2994
|
+
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)'),
|
|
2974
2995
|
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'),
|
|
2975
2996
|
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)'),
|
|
2976
2997
|
query: z.string().optional().describe('[search] term to match against names/content'),
|
|
@@ -3014,7 +3035,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3014
3035
|
}
|
|
3015
3036
|
to = toScoped.path;
|
|
3016
3037
|
}
|
|
3017
|
-
|
|
3038
|
+
// Folder chain (Wave 0): folder is the single recursive container and the org
|
|
3039
|
+
// is that node at depth 0, so EVERY level carries the same four roots. The
|
|
3040
|
+
// root keyword is the separator — everything before wiki/skills/tasks/projects
|
|
3041
|
+
// is the folder chain, everything after is the path within that root. The
|
|
3042
|
+
// folder rides the REQUEST (X-Drafted-Folder); it is never stored, because a
|
|
3043
|
+
// "current folder" would be the retired org cursor rebuilt one level down.
|
|
3044
|
+
const pScope = liftFolderScope(p, to);
|
|
3045
|
+
if (pScope.error) return err(new Error(`${action} ${pScope.error}`));
|
|
3046
|
+
const folder = pScope.folder;
|
|
3047
|
+
to = pScope.to;
|
|
3048
|
+
const orgHeader = {
|
|
3049
|
+
...(org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {})),
|
|
3050
|
+
...(folder ? { 'X-Drafted-Folder': folder } : {}),
|
|
3051
|
+
};
|
|
3052
|
+
// The folder chain has been lifted into the header — everything below
|
|
3053
|
+
// dispatches on the ROOT, at any depth, with no per-root folder handling.
|
|
3054
|
+
const folderBase = `${orgFromPath ? `/o/${orgFromPath}` : ''}${folder ? '/' + folder : ''}`;
|
|
3055
|
+
if (pScope.rest) p = pScope.rest;
|
|
3018
3056
|
const gs = getSessionState().gates;
|
|
3019
3057
|
|
|
3020
3058
|
// ── Root listing: / or empty → the orgs this session can address (Shape A) ──
|
|
@@ -3049,7 +3087,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3049
3087
|
|
|
3050
3088
|
section('Wiki', wiki, (v) => {
|
|
3051
3089
|
const hits = (v?.hits || v?.pages || v?.results || []).slice(0, 10);
|
|
3052
|
-
return hits.length ? hits.map(h => ` ${scope}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
|
|
3090
|
+
return hits.length ? hits.map(h => ` ${scope}${h.folder ? `/${h.folder}` : ''}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
|
|
3053
3091
|
});
|
|
3054
3092
|
section('Skills', skills, (v) => {
|
|
3055
3093
|
// Fewer than the other legs on purpose: skills search is fuzzy and its tail
|
|
@@ -3080,13 +3118,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3080
3118
|
}
|
|
3081
3119
|
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'));
|
|
3082
3120
|
if (orgFromPath) {
|
|
3083
|
-
// ls /o/<org> →
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
{ name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
|
|
3087
|
-
{ name: 'tasks', type: 'directory', path: `/o/${orgFromPath}/tasks`, hint: 'work items — a task is a frame; status/assignee are front matter on read' },
|
|
3088
|
-
{ name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
|
|
3089
|
-
]);
|
|
3121
|
+
// ls /o/<org> → the org IS the depth-0 folder: its four roots, plus the
|
|
3122
|
+
// folders nested inside it (each carrying the same four).
|
|
3123
|
+
return ok([...rootEntries(`/o/${orgFromPath}`), ...await listSubfolders(`/o/${orgFromPath}`, '', orgHeader)]);
|
|
3090
3124
|
}
|
|
3091
3125
|
// ls / → the orgs (id, slug, name) the agent belongs to; org is the top folder
|
|
3092
3126
|
const orgs = await getOrgList();
|
|
@@ -3094,10 +3128,33 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3094
3128
|
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' })));
|
|
3095
3129
|
}
|
|
3096
3130
|
|
|
3131
|
+
// ── A path with no root keyword names a FOLDER, at any depth ──────
|
|
3132
|
+
// /o/<org>/engineering, /o/<org>/engineering/backend, … Same shape as the org
|
|
3133
|
+
// itself: four roots plus whatever folders nest inside.
|
|
3134
|
+
if (!pScope.rest && folder) {
|
|
3135
|
+
if (action === 'ls') {
|
|
3136
|
+
return ok([...rootEntries(folderBase), ...await listSubfolders(folderBase, folder, orgHeader)]);
|
|
3137
|
+
}
|
|
3138
|
+
if (action === 'mkdir') {
|
|
3139
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3140
|
+
const created = await api('POST', '/api/folders', { name: folder }, orgHeader);
|
|
3141
|
+
return ok({ created: true, folder: created?.name || folder, id: created?.id, path: folderBase,
|
|
3142
|
+
hint: 'a folder carries its own wiki, skills, tasks and projects' });
|
|
3143
|
+
}
|
|
3144
|
+
if (action === 'rm') {
|
|
3145
|
+
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3146
|
+
const rows = await api('GET', '/api/folders', undefined, orgHeader);
|
|
3147
|
+
const row = (Array.isArray(rows) ? rows : []).find((f) => f.name === folder);
|
|
3148
|
+
if (!row) return err(new Error(`no such folder: ${folder}`));
|
|
3149
|
+
return ok(await api('DELETE', `/api/folders/${row.id}`, undefined, orgHeader));
|
|
3150
|
+
}
|
|
3151
|
+
return err(new Error(`${folderBase} is a folder — address one of its roots: ${ROOT_HINT.replace('<base>', folderBase)}`));
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3097
3154
|
// ── mkdir: create a project (a directory in the /projects root) ──
|
|
3098
3155
|
if (action === 'mkdir') {
|
|
3099
3156
|
if (!p.startsWith('/projects')) {
|
|
3100
|
-
return err(new Error('mkdir
|
|
3157
|
+
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.'));
|
|
3101
3158
|
}
|
|
3102
3159
|
// A project create is project-less: it must not guess the org.
|
|
3103
3160
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
@@ -3105,18 +3162,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3105
3162
|
if (parts.length === 0 || parts.length > 2) {
|
|
3106
3163
|
return err(new Error(`mkdir path must be /projects/<name> or /projects/<folder>/<name> — got ${p}`));
|
|
3107
3164
|
}
|
|
3108
|
-
|
|
3165
|
+
// Two spellings reach the same place: the folder chain before the root
|
|
3166
|
+
// (/o/<org>/eng/projects/x) or the legacy segment after it
|
|
3167
|
+
// (/o/<org>/projects/eng/x). The chain wins when both are given.
|
|
3168
|
+
const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
|
|
3109
3169
|
const projectRef = parts[parts.length - 1];
|
|
3110
3170
|
const existing = await resolveProjectRef(projectRef).catch(() => null);
|
|
3111
3171
|
if (existing) return err(new Error(`project already exists: ${projectRef}`));
|
|
3112
3172
|
const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
3113
3173
|
const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
|
|
3114
3174
|
if (!created?.id) return err(new Error('project create returned no id'));
|
|
3115
|
-
if (
|
|
3116
|
-
try { await api('PATCH', `/api/project/${created.id}`, { folder }, orgHeader); } catch { /* folder best-effort */ }
|
|
3175
|
+
if (projectFolder) {
|
|
3176
|
+
try { await api('PATCH', `/api/project/${created.id}`, { folder: projectFolder }, orgHeader); } catch { /* folder best-effort */ }
|
|
3117
3177
|
}
|
|
3118
3178
|
projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
|
|
3119
|
-
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${
|
|
3179
|
+
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}` });
|
|
3120
3180
|
}
|
|
3121
3181
|
|
|
3122
3182
|
// ── Root: /wiki/... ────────────────────────────────────────────
|
|
@@ -3615,7 +3675,19 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3615
3675
|
// __archived folder. Frame/lane rm still deletes those items, but a
|
|
3616
3676
|
// whole project is never hard-deleted by an agent — the web UI's
|
|
3617
3677
|
// Archive bin is where a human permanently deletes.
|
|
3618
|
-
|
|
3678
|
+
// A layer or lane path has no filename, so it used to fall through to
|
|
3679
|
+
// the archive branch and archive the WHOLE project — with a
|
|
3680
|
+
// success-shaped response. /slides/deck and /slides are one keystroke
|
|
3681
|
+
// from the project path; only the bare project path may archive.
|
|
3682
|
+
const scope = rmScope(layer, lane, filename);
|
|
3683
|
+
if (scope === 'directory') {
|
|
3684
|
+
return err(new Error(
|
|
3685
|
+
`rm on a ${lane ? 'lane' : 'layer'} path deletes nothing — Drafted has no directory delete. ` +
|
|
3686
|
+
`Remove the frames individually: /projects/<project>/${layer}${lane ? '/' + lane : ''}/<file>. ` +
|
|
3687
|
+
`To archive the whole project, address the project itself: /projects/<project>`
|
|
3688
|
+
));
|
|
3689
|
+
}
|
|
3690
|
+
if (scope === 'project') {
|
|
3619
3691
|
const pid = getState().projectId;
|
|
3620
3692
|
if (!pid) return err(new Error('could not resolve project id for archive'));
|
|
3621
3693
|
const result = await api('PATCH', `/api/project/${pid}`, { folder: '__archived' }, orgHeader);
|
|
@@ -3730,12 +3802,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3730
3802
|
// Agents reach the repos table through this tool. Content stays in git;
|
|
3731
3803
|
// Drafted keeps a read-only index. `add`/`rescan` mutate; `list`/`entries`
|
|
3732
3804
|
// are read-only and paginated with a compact mode (collection rule).
|
|
3733
|
-
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
|
|
3734
|
-
action: z.enum(['list', '
|
|
3735
|
-
|
|
3736
|
-
folder: z.string().optional().describe('[add] folder name to link the repo into (required for add; repos are folder-scoped).'),
|
|
3737
|
-
branch: z.string().optional().describe('[add] git branch to track (default: the repo default; fails loudly if the branch is missing).'),
|
|
3738
|
-
slug: z.string().optional().describe('[add] repo slug (defaults to the repo name from the URL); [rescan] the repo slug or UUID.'),
|
|
3805
|
+
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\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- `rescan` — re-fetch the tracked branch and refresh the index.\n- `entries` — search the index across all the org\'s repos (skills + identities).\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.', {
|
|
3806
|
+
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.'),
|
|
3807
|
+
slug: z.string().optional().describe('[rescan] the repo slug or UUID.'),
|
|
3739
3808
|
repoId: z.string().optional().describe('[rescan] repo UUID (alternative to slug).'),
|
|
3740
3809
|
query: z.string().optional().describe('[entries] search term (matches name/description/slug).'),
|
|
3741
3810
|
kind: z.enum(['skill', 'identity']).optional().describe('[entries] filter to skill or identity.'),
|
|
@@ -3744,7 +3813,7 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
|
|
|
3744
3813
|
compact: z.boolean().optional().describe('list/entries: return only identity fields {slug,name,gitUrl} / {slug,name,kind} (default false).'),
|
|
3745
3814
|
org: z.string().optional().describe('Org (id or name) to scope this call; defaults to the session\'s working org.'),
|
|
3746
3815
|
}, async (args) => {
|
|
3747
|
-
const { action,
|
|
3816
|
+
const { action, slug, repoId, query, kind, limit, offset, compact, org } = args;
|
|
3748
3817
|
const orgHeader = org ? { 'X-Drafted-Org': org } : {};
|
|
3749
3818
|
const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
|
|
3750
3819
|
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
|
@@ -3760,17 +3829,6 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
|
|
|
3760
3829
|
}))),
|
|
3761
3830
|
});
|
|
3762
3831
|
}
|
|
3763
|
-
if (action === 'add') {
|
|
3764
|
-
if (!gitUrl) return err(new Error('add requires gitUrl (e.g. https://github.com/owner/repo)'));
|
|
3765
|
-
if (!folder) return err(new Error('add requires folder (the folder name to link the repo into — repos are folder-scoped)'));
|
|
3766
|
-
// Resolve folder name -> id in the target org.
|
|
3767
|
-
const foldersRes = await api('GET', '/api/folders', undefined, orgHeader);
|
|
3768
|
-
const folders = Array.isArray(foldersRes) ? foldersRes : (foldersRes?.folders || []);
|
|
3769
|
-
const f = folders.find((x) => x.name === folder);
|
|
3770
|
-
if (!f) return err(new Error(`no folder named "${folder}" in this org`));
|
|
3771
|
-
const result = await api('POST', '/api/repos', { gitUrl, folderId: f.id, branch: branch || undefined, slug: slug || undefined }, orgHeader);
|
|
3772
|
-
return ok({ slug: result.slug, gitUrl: result.gitUrl, branch: result.branch, skills: result.skills, identities: result.identities });
|
|
3773
|
-
}
|
|
3774
3832
|
if (action === 'rescan') {
|
|
3775
3833
|
const id = repoId || await (async () => {
|
|
3776
3834
|
if (!slug) return null;
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { mkdtempSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin } from './server.mjs';
|
|
8
|
+
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin, rmScope } from './server.mjs';
|
|
9
9
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
10
10
|
|
|
11
11
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
@@ -168,6 +168,16 @@ assert.equal(stripUrlOrigin('/o/acme/wiki/x'), '/o/acme/wiki/x', 'plain paths pa
|
|
|
168
168
|
assert.equal(stripUrlOrigin('not a url'), 'not a url', 'non-URL input passes through');
|
|
169
169
|
assert.equal(stripUrlOrigin('/f/00000000-0000-0000-0000-000000000000'), '/f/00000000-0000-0000-0000-000000000000', '/f/ frame links pass through');
|
|
170
170
|
|
|
171
|
+
// fs(rm) scope: only a BARE project path may archive a project. A lane path
|
|
172
|
+
// (/slides/deck) and a layer path (/slides) both parse with filename=null and
|
|
173
|
+
// used to fall through to the archive branch, silently archiving the whole
|
|
174
|
+
// project with a success-shaped response.
|
|
175
|
+
assert.equal(rmScope('slides', 'deck', '12.html'), 'file', 'a full frame path removes that frame');
|
|
176
|
+
assert.equal(rmScope('slides', null, '12.html'), 'file', 'a layer-root file path removes that frame');
|
|
177
|
+
assert.equal(rmScope('slides', 'deck', null), 'directory', 'a LANE path must never archive the project');
|
|
178
|
+
assert.equal(rmScope('slides', null, null), 'directory', 'a LAYER path must never archive the project');
|
|
179
|
+
assert.equal(rmScope(null, null, null), 'project', 'only the bare project path archives the project');
|
|
180
|
+
|
|
171
181
|
console.log('org-guard policy OK');
|
|
172
182
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
173
183
|
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.17",
|
|
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
|
+
}
|