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 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
- + `Run \`drafted login\` to sign in. `
306
- + `The CLI and MCP share ${DEFAULT_AUTH_FILE}, so signing in once works for both.`;
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
- const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities)');
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('add <url>')
2150
- .description('Link a GitHub repo to an org FOLDER the server fetches it and indexes .agents/ skills/identities (repos are folder-scoped, never org-level). --branch points at a named branch (fails loudly if missing instead of guessing).')
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 (url, opts) => {
2158
- requireLogin();
2159
- const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
2160
- const server = getServerUrl().replace(/\/$/, '');
2161
- const fail = (msg) => { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:add', error: msg })); else console.log(['error', msg].join('\t')); process.exit(1); };
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('list')
2189
- .description('List the org\'s linked repos with their folder + .agents/ index counts')
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 data = await readApiGet('repo:list', '/api/repos', opts.org);
2194
- const rows = data.repos || [];
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
- for (const r of rows) console.log([r.slug, r.folderName || '-', r.gitUrl, `branch:${r.branch || '-'}`, `skills:${r.skillCount ?? 0}`, `identities:${r.identityCount ?? 0}`].join('\t'));
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/ index (idempotent; reports staleness via lastScannedAt/Sha)')
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 and <dir>/.agents/identities/<slug>/
34
- * identity.yaml. Returns { skills, identities } catalog entries (metadata +
35
- * pointers only — no file content). A skill with no frontmatter still indexes
36
- * (name falls back to the dir). Identities index {slug, name} from identity.yaml.
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. On a local install the DESKTOP APP is the sign-in surface: both actions open the Drafted app to its sign-in window no device link is shown. `action=login` opens the app and waits for the in-app sign-in to complete; `action=get_link` opens the app and returns immediately (the next Drafted tool call picks up the captured session). A device-code link is used ONLY as a fallback on platforms without the desktop app (the web MCP uses OAuth2, not this tool).', {
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
- // Local install open the desktop app's sign-in window (no link). Falls through to the
2080
- // device-code flow below only when no desktop app is installed on this platform.
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>/wiki/<path>',
2972
- skills: '/o/<org>/skills/<slug>',
2973
- tasks: '/o/<org>/tasks/<lane?>/<file>',
2974
- projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
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. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/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- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org.)\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.', {
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>/wiki/... | /o/<org>/skills/... | /o/<org>/tasks/... | /o/<org>/projects/... (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
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
- const orgHeader = org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {});
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> → that org's roots
3095
- return ok([
3096
- { name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
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 is only meaningful under /projects (wiki dirs are implicit; skills are flat) e.g. fs(mkdir, path="/o/<org>/projects/my-project")'));
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
- const folder = parts.length === 2 ? parts[0] : null;
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 (folder) {
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: `${orgFromPath ? `/o/${orgFromPath}` : ''}/projects${folder ? '/' + folder : ''}/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
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- `add` — link a GitHub repo to a folder; the server fetches + indexes .agents/. `branch` points at a named branch (fails loudly if missing).\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.', {
3757
- action: z.enum(['list', 'add', 'rescan', 'entries']).describe('list: org repos; add: link a repo; rescan: re-fetch the tracked branch; entries: search the index.'),
3758
- gitUrl: z.string().optional().describe('[add] GitHub repo URL to link (e.g. https://github.com/owner/repo). Required for add.'),
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, gitUrl, folder, branch, slug, repoId, query, kind, limit, offset, compact, org } = args;
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.16",
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
+ }