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 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
- const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities)');
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('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).')
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 (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
- }
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('list')
2189
- .description('List the org\'s linked repos with their folder + .agents/ index counts')
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 data = await readApiGet('repo:list', '/api/repos', opts.org);
2194
- const rows = data.repos || [];
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
- 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'));
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/ index (idempotent; reports staleness via lastScannedAt/Sha)')
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 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';
@@ -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>/wiki/<path>',
2961
- skills: '/o/<org>/skills/<slug>',
2962
- tasks: '/o/<org>/tasks/<lane?>/<file>',
2963
- projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
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. 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.', {
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>/wiki/... | /o/<org>/skills/... | /o/<org>/tasks/... | /o/<org>/projects/... (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
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
- const orgHeader = org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {});
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> → that org's roots
3084
- return ok([
3085
- { name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
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 is only meaningful under /projects (wiki dirs are implicit; skills are flat) e.g. fs(mkdir, path="/o/<org>/projects/my-project")'));
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
- const folder = parts.length === 2 ? parts[0] : null;
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 (folder) {
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: `${orgFromPath ? `/o/${orgFromPath}` : ''}/projects${folder ? '/' + folder : ''}/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
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
- if (!filePath) {
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- `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.', {
3734
- 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.'),
3735
- gitUrl: z.string().optional().describe('[add] GitHub repo URL to link (e.g. https://github.com/owner/repo). Required for add.'),
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, gitUrl, folder, branch, slug, repoId, query, kind, limit, offset, compact, org } = args;
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;
@@ -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.11",
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
+ }